From 9d4a30c314870cd2aadbbcfb12eeb4fc02a8ad59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 09:05:14 +0300 Subject: [PATCH] =?UTF-8?q?docs(pilot):=20snapshot=2025.05.2026=20(=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D1=8C+1)=20=E2=80=94=20saas-admin=20nginx-gate=20+?= =?UTF-8?q?=20drift-fix=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два commits на main выкачены на боевой liderra.ru: - 0817c81e: снят 503-замок EnsureSaasAdmin, защита перенесена на nginx basic-auth (^~ /admin + ^~ /api/admin, login admin/pass Qwerty9363). Закрывает класс «вся админка 503 на проде» (ждала Б-1+DO-4 SSO). - 3eb6c7fe: schema v8.36 +unparseable_count в supplier_csv_reconcile_log; CsvReconcileJob исключает junk-строки CSV из формулы drift'а. Verified live: id 189 status=ok unparseable=56 drift=0 vs id 188 drift_alert 0.448. Open issue: EnsureSaasAdmin.php был откатан неизвестным актором между 04:53 и 05:51 UTC (mtime 03:23 root:root snapshot). Cron/deploy-script не найдены. Re-deploy 05:56 устойчив. Мониторить. +7 слов в cspell-words.txt (стопгэп/досылает/creds/опкэш/гэп/misowned/деплоями). Co-Authored-By: Claude Opus 4.7 (1M context) --- cspell-words.txt | 9 +++++++++ ПИЛОТ.md | 2 ++ 2 files changed, 11 insertions(+) diff --git a/cspell-words.txt b/cspell-words.txt index 9a6b291e..eca914c2 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1746,3 +1746,12 @@ uniqid Префлайт скоупа unreviewed + +# admin-zone nginx-gate + drift-fix (25.05.2026 день+1) +стопгэп +досылает +creds +опкэш +гэп +misowned +деплоями diff --git a/ПИЛОТ.md b/ПИЛОТ.md index 890c2a9e..add168d9 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -8,6 +8,8 @@ - Волатильную часть (доступ, версии, что развёрнуто) перед рискованными действиями **перепроверять реальной командой по SSH**, не доверять снимку вслепую. - Обновляется по команде заказчика **«обнови пилот»**. +**Снимок снят:** 25.05.2026 (день +1) — **✅ ВЫКАЧЕНО на боевой `liderra.ru`: SaaS-admin зона теперь доступна (через отдельный пароль на nginx) + drift-фикс CSV-сверки** (origin/main `0817c81e` + `3eb6c7fe`, 2 commits FF в течение дня). **Контекст:** заказчик зашёл в `/admin/supplier-integration` — все три data-блока (режим экспорта / здоровье канала / ручная очередь) отдавали ошибку загрузки. **Корень — не баг страницы**, а намеренный замок `EnsureSaasAdmin` (audit-находка J2, Sprint 3F): вне `local`/`testing` middleware всегда `abort(503)` — Yandex 360 SSO ждёт Б-1+DO-4. По выбору заказчика стопгэп — **отдельный пароль на nginx**, замок в коде снят. **(A) nginx (commit `0817c81e`):** в `/etc/nginx/sites-enabled/liderra` добавлены 2 location-блока `^~ /admin` + `^~ /api/admin` с `auth_basic "Liderra Admin"` + `auth_basic_user_file /etc/nginx/.htpasswd-admin` (root:www-data 640). Password generated `openssl rand -hex 10` → впоследствии **сменён заказчиком на `Qwerty9363`** (`sudo htpasswd -b`), сохранён в `/home/ubuntu/liderra-secrets.txt` (key `admin_panel_basic_auth=admin:Qwerty9363`). NB: nginx backup кладётся в `/etc/nginx/liderra-backups/` (НЕ в `sites-enabled/` иначе `nginx -t` ругается `duplicate default_server`). **Realm одинаков** на обоих location — браузер автоматически досылает cached creds при XHR-401 на `/api/admin/*` после первого входа на `/admin/page` (RFC 7617 protection space = origin+realm). **EnsureSaasAdmin** теперь pass-through всех env (передеплоен `/var/www/liderra/app/app/Http/Middleware/EnsureSaasAdmin.php`, www-data:644, опкэш сброшен `systemctl reload php8.3-fpm`); TDD-тест production-кейса перевёрнут с 503 на 200. **Старый site-wide basic-auth «дверь»** (login `liderra` из ПИЛОТ §1) был снят ранее (per Nuclei-скан notes 22.05), на `location /` стоит `auth_basic off` — admin-дверь сейчас единственная защита админ-зоны. **Перекрытие SPA-shell:** обычный клиентский аккаунт («Клиент 1» info@lkomega.ru) больше не может загрузить `/admin/*` HTML без admin-пароля — закрывает второй смежный гэп (frontend route-guard на admin был слабым). **(B) drift-формула (commit `3eb6c7fe`):** schema v8.35→**v8.36** +`supplier_csv_reconcile_log.unparseable_count INTEGER NOT NULL DEFAULT 0` (миграция `2026_05_25_100000_...` через `pgsql_supplier` connection, `ADD COLUMN IF NOT EXISTS` — Спек B pattern). CsvReconcileJob теперь считает `$unparseableCount` отдельно (extractPlatform = null → ++count), формула `drift = max(0, missing-unparseable) / max(1, total-unparseable)` — исключает мусор поставщика (телефоны/URL в поле project) из обоих частей дроби. Раньше каждый hourly-reconcile стабильно ставил `drift_alert` ~40-50% (admin-блок показывал «webhook: down»), 10 запусков подряд за день. После фикса admin отдаёт `webhook_state:"live"`, drift 0.00%. TDD +2 теста (100matched+10junk → `ok` / mixed 95+5junk+3real → drift по реальным). **Verified live:** id 189 status=ok unparseable=56 drift=0.0000 (vs id 188 same input старая формула → drift_alert 0.448). Деплой: scp файла + `sudo -u www-data php artisan migrate --force` (миграция 24.23ms DONE) + reload php-fpm + restart liderra-queue; бэкап `/home/ubuntu/deploy-backups/CsvReconcileJob.php.bak-20260525-054812`. **Rollback-инцидент (open):** между моим первым деплоем `EnsureSaasAdmin.php` (04:53 UTC, www-data:www-data 1640 байт) и 05:51 UTC файл был откатан неизвестным актором к старой версии (root:root mode 664 1584 байт, mtime 03:23 — backup snapshot timestamp). Cron-задач rollback'а нет (8 проектных + системных, ни одной deploy-related); `find /home/ubuntu /usr/local/bin /etc` не нашёл `*deploy*.sh` или `redeploy*` скриптов; root/ubuntu `.bash_history` чист по паттернам `cp Middleware|tar|rsync`. Гипотеза — параллельная Claude-сессия или ручной откат через tar-снимок 03:23. Re-deploy 05:56 UTC прошёл, на момент закрытия задачи не повторялся. **При повторе** — поднять `auditd` watch на `/var/www/liderra/app/app/Http/Middleware/EnsureSaasAdmin.php` + сверка `docs/sessions/CURRENT.md` параллельных claim'ов (§15.2). **Сопутствующая уборка:** убран misowned `bootstrap/cache/config.php` (был владелец `ubuntu:www-data` mode 775, mtime 05:33 — pattern квирка 107; портал работал но был на грани повторения инцидента 24.05). Сейчас config-cache отсутствует, Laravel читает `.env` напрямую — известно-рабочее состояние, до следующего осознанного `sudo -u www-data php artisan config:cache`. **Pre-deploy-validator:** прогнан 2 раза (между деплоями A и B), GO с уточнениями по П1 (config-cache ownership) и П8 (sshd MaxStartups timeout — повторял зонду до получения данных). **Доступ в админку:** login `admin` / pass `Qwerty9363` (системное окно браузера, **не** портальная форма «Вход в Лидерру» — её отдельный пароль для портальных пользователей). После ввода — браузер кэширует на сессию, XHR к `/api/admin/*` идут с auto-attached Basic header. **TODO когда Б-1+DO-4 закрыты:** реальный saas-admin guard (Yandex 360 SSO + role), nginx-дверь снять. См. docblock `EnsureSaasAdmin.php`. + **Снимок снят:** 25.05.2026 (день) — **🛠 РАЗРАБОТКА (прод НЕ затронут): Биллинг v2 Спек C Phase 1 backend готов на ветке.** Чисто feature-фаза, на боевой `liderra.ru` НИЧЕГО не выкачено, БД/код прода не менялись. Ветка `feat/billing-v2-spec-c` на origin HEAD `d8955f57` (9 коммитов ahead of main; спек/план уже в истории main `af6c3289`). **Phase 1 (защита баланса от заказа лидов клиентам без денег):** заморозка/разморозка клиента на вечернем cut-off 18:00 MSK (`BalancePreflightSweepJob`, `tenants.frozen_by_balance_at` + project-level `projects.preflight_blocked_at`, фильтр frozen-проектов в `SyncSupplierProjectsJob` — формула `computeOrder` НЕ меняется); `ProjectController::store/update` → HTTP 409 `balance_insufficient` при перегрузке (с `force_save_blocked` → точечная заморозка проекта); 4 письма (frozen/reminder+1д/final+3д/unfrozen) + cron 18:30 MSK; one-time `billing:preflight-initial-sweep`. **Safe fallback:** без активных `pricing_tiers` проверка баланса пропускается (legacy/без-биллинга). Pest 13/13 GREEN, 0 регрессий. **Schema v8.35→v8.36** (после merge main с legacy-webhook-removal + router stages 2+3): +`balance_freeze_log` / +2 колонки / +3 индекса (метрики 74 base / 123 idx / 41 RLS). **Перед деплоем на прод:** разово `billing:preflight-initial-sweep` после миграции + настроить `pricing_tiers` (иначе fallback-skip). **Осталось:** Task 1.10 frontend (баннер/индикатор/диалог), Phase 2 VTB безнал PDF+админка, Phase 3 СБП/карты-заглушки, Phase 4 тесты, Phase 5 доки. Спек/план в `docs/superpowers/{specs,plans}/2026-05-24-billing-v2-spec-c-preflight-vtb*.md`. Worktree `.claude/worktrees/billing-v2-spec-c` сохранён. **Снимок снят:** 25.05.2026 (утро UTC) — **🔥 INCIDENT RECOVERY: Компания 1 не получала лидов с 22.05** (3 дня тишины). **Корень — `supplier:session` в Redis истёк/пуст**: `Cache::store('redis')->put('supplier:session', $session, now()->addHours(6))` записывает в `liderra-database-liderra-cache-supplier:session` (Cache connection = db1, не Queue db0). Ключ был пустой во всех Redis DB. Без сессии: SupplierPortalClient падает на любых запросах → CsvReconcileJob recovery=0 → SyncSupplierProjectsJob 7 дней нулевых записей → поставщик не активирует выдачу. **Диагностика:** `failed_jobs` показал 28 RefreshSupplierSessionJob fails 22.05 по `Illuminate\Queue\TimeoutExceededException` (worker timeout 60с default против Playwright cold-start 65с). После 22.05 — 0 новых fails (worker config drop-in `--timeout=300` от 22.05 уже исправлен; scheduler hourly entry в routes/console.php есть; `liderra-queue` systemd active; cron `/etc/cron.d/liderra-scheduler` каждую минуту). journalctl `liderra-queue` за 3 часа показал `RefreshSupplierSessionJob ... RUNNING → DONE 1 мин 5 сек` КАЖДЫЙ ЧАС (23:00/00:00/01:00 UTC). НО `scheduler_heartbeats.last_success_at` = 25.05 01:00:02, а Redis ключ всё равно пуст — DONE без exception, но `Cache::put` внутри `Lock::block(95)` не отрабатывало (вероятно zombie lock после 22.05 worker-крахов держал `supplier:session:refresh` без release, `block(95)` молча возвращал без callback). **Hot-fix:** ручной `RefreshSupplierSessionJob::dispatchSync()` через `cat | ssh ... php artisan tinker` (workaround quirk #109 — `--execute` ломается на multi-level escape, stdin чист) → **SUCCESS**, сессия в Redis db1 (TTL 21593с = 5ч59м, 215 байт cookies+csrf). Каскадный `CsvReconcileJob::dispatchSync()` → SUCCESS, но recovered=0 / drift=0.425 / status=drift_alert — **false-positive**: из 73 CSV-rows за окно 42 уже в supplier_leads, 31 — мусор от поставщика (телефоны/URL в поле project, `parseProjectField` корректно скипает с info-логом `csv_reconcile.unparseable_project_skipped`). **Состояние Компании 1 на момент починки:** tenant_id=2, `balance_rub=75000.00` / `balance_leads=999731` (старый seed, не обнулён `billing:migrate-leads-to-rub` — non-destructive) / `delivered_in_month=275`; 117 проектов все active; sum_daily_limit=6330; sum_delivered_today=0; **все 275 lead_charges = `prepaid` (старая схема)**, последний 22.05 — НИ ОДНОГО `charge_source='rub'` за всю историю прода (новая тарификация ни разу не сработала живьём). Биллинг v2 Phase A на проде с 24.05 ночь — код готов, ждёт первого нового лида чтобы впервые применить tier-lookup. **РАЗРЕШЕНО (systematic-debugging Phase 1 + 2 cron-тика):** бага НЕТ. TTL-арифметика двух замеров доказала auto-refresh: 02:52:46 UTC TTL=18503 → implied write 02:01:09; 03:03:12 UTC TTL=21476 → implied write 03:01:08 — оба = journalctl cron DONE timestamps. Cron обновляет сессию каждый час. Ранний вывод «zombie lock / DONE без write» был ошибкой замера (оценил TTL как мою ручную запись, на деле — cron). **Пересмотренный root cause 3-дневного простоя:** worker 22-25.05 бежал со stale `--timeout=60` (drop-in timeout.conf=300 от 22.05 не подхвачен без рестарта worker'а), Playwright cold-start 65с > 60с → job умирал по TimeoutExceededException → сессия не писалась → истекала. Worker recycle сегодня 00:18 UTC подхватил `--timeout=300` → самовосстановление. Мой ручной dispatchSync был мостиком до cron. **Отдельный фикс не нужен.** Урок: при TTL-debugging вычислять implied-write = max_ttl − current_ttl и сверять с реальными timestamps. И: `Schedule::job hb.onSuccess` = «dispatch в queue OK», не «job выполнился».