diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 178e1246..b599c368 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -233,6 +233,10 @@ jobs: # уезжает в main зелёной. Ровно то, что осуждает комментарий выше. - '.forgejo/workflows/deploy-metrics.yml' - 'docker-compose.metrics.yml' + # #3486: гейт test_metrics_single_file_mounts.py читает и + # compose агентов — правка ТОЛЬКО его (новый пофайловый маунт у + # экспортёра) без этой строки прошла бы мимо гейта. + - 'docker-compose.metrics-agent.yml' # #3443: тот же класс, третий раз. Гейт # backend/tests/ops/test_3443_caddy_reload_not_recreate.py не читает # ops/caddy-apply.sh, а ИСПОЛНЯЕТ его с подставным `docker` — то есть diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 6d30ecb9..01407644 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -388,6 +388,15 @@ jobs: echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду." fi + # ── Loki: конфиг — бинд-маунт ОДНОГО файла (#3467) ───────────── + # Та же ловушка инода, что у Alertmanager выше, а перезагрузки + # основного конфига у Loki нет вовсе: правка loki-config.yml ложилась + # на диск и не вступала в силу до случайного пересоздания. + # Пересоздаём только при расхождении инода. + COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \ + gendesign-metrics docker-compose.metrics.yml \ + loki ops/metrics/loki/loki-config.yml /etc/loki/loki-config.yml + # ── Caddy: СНАЧАЛА проверить, потом применять ────────────────── # На этом хосте тот же Caddy обслуживает git., errors. и obsidian. # Синтаксическая ошибка в infra.caddy положила бы их все, включая @@ -406,15 +415,6 @@ jobs: fi fi - # ── Приёмка ──────────────────────────────────────────────────── - for i in $(seq 1 30); do - if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then - break - fi - sleep 3 - done - docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps - # ── Prometheus: конфиг/правила лежат на диске, `up -d` их не # перечитывает ──────────────────────────────────────────────── # Тот же класс бага, что у Caddyfile и alertmanager.yml выше: @@ -424,27 +424,41 @@ jobs: # незамеченными (#3467): lastConfigTime совпадал со startTime # контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**. # - # У Prometheus, в отличие от Alertmanager (см. комментарий выше), - # /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод - # после `git reset --hard` подхватывается без пересоздания - # контейнера. --web.enable-lifecycle уже включён в compose ради - # этого шага (см. docker-compose.metrics.yml) — просто раньше - # никто не звал сам reload. + # /-/reload перечитывает правила: они подключены КАТАЛОГОМ, и новый + # файл контейнер видит сразу. А prometheus.yml — бинд-маунт ОДНОГО + # файла: после `git reset --hard` за путём в контейнере остаётся + # СТАРЫЙ инод, и reload честно перечитывает старый текст с rc=0 и + # новым lastConfigTime (проверено на prom/prometheus:v3.1.0, 17.09). + # Поэтому при расхождении инода контейнер пересоздаётся (#3467). + # --web.enable-lifecycle включён в compose ради этого шага. # - # promtool проверяет ОБА файла ДО reload: битый конфиг не должен - # положить работающий Prometheus молчаливым откатом на дефолты. - # - # Плюс юнит-тесты правил (#3493): синтаксически верное правило может - # врать по смыслу — `count(x == 1) == 0` от пустого вектора не - # срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый - # экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому - # одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска. - if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \ - && docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \ - && docker run --rm --entrypoint promtool \ - -v /opt/gendesign/ops/metrics/prometheus:/work:ro \ - "$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \ - test rules /work/tests/infra_test.yml; then + # promtool проверяет конфиг, правила и их юнит-тесты (#3493) ДО + # любого применения, и именно ФАЙЛЫ С ДИСКА — одноразовым + # контейнером того же образа. `docker exec` в работающий проверял + # бы тот самый старый инод, а не то, что сейчас применится. + # Синтаксически верное правило может врать по смыслу — + # `count(x == 1) == 0` от пустого вектора не срабатывает никогда, — + # отсюда test rules. + if docker run --rm --entrypoint sh \ + -v /opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro \ + "$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \ + -c 'promtool check config /etc/prometheus/prometheus.yml && promtool check rules /etc/prometheus/rules/*.yml && promtool test rules /etc/prometheus/tests/infra_test.yml'; then + COMPOSE_PROFILES="$PROFILES" sh ops/metrics/recreate-stale-mount.sh \ + gendesign-metrics docker-compose.metrics.yml \ + prometheus ops/metrics/prometheus/prometheus.yml /etc/prometheus/prometheus.yml + + # ── Приёмка ──────────────────────────────────────────────── + # После возможного пересоздания выше — ждём оба процесса, + # которые дальше перечитывают конфиг по HTTP. + for i in $(seq 1 30); do + if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null \ + && docker exec gendesign-grafana wget -q --spider http://localhost:3000/api/health 2>/dev/null; then + break + fi + sleep 3 + done + docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps + LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')" docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload @@ -470,6 +484,30 @@ jobs: exit 1 fi + # ── Grafana: датасорсы применяются только при старте (#3467) ──── + # Каталог provisioning смонтирован целиком, новый datasources.yml + # контейнер видит сразу. Дашборды провайдер пересканирует сам + # (updateIntervalSeconds: 30), а датасорсы — нет: на стенде + # grafana:11.5.1 изменённый url не применился и через 75 с, POST + # на ручку ниже применил сразу (замер в PR #3475). Без этого шага + # правка датасорса лежала бы на диске без эффекта при зелёном деплое. + # + # Пароль раскрывается ВНУТРИ контейнера: в argv хоста и в лог деплоя + # он не попадает. wget отдаёт rc≠0 на 401/5xx; текст ответа сверяем + # отдельно, чтобы «200 не от той ручки» не сошло за успех. + GRAFANA_RELOAD="$(docker exec gendesign-grafana sh -c \ + 'wget -q -O- --post-data="" --header="Authorization: Basic $(printf "%s:%s" "${GF_SECURITY_ADMIN_USER:-admin}" "${GF_SECURITY_ADMIN_PASSWORD}" | base64 -w0)" http://localhost:3000/api/admin/provisioning/datasources/reload' \ + 2>&1)" || GRAFANA_RELOAD="rc=$? $GRAFANA_RELOAD" + case "$GRAFANA_RELOAD" in + *"Datasources config reloaded"*) + echo "Grafana: датасорсы перечитаны." + ;; + *) + echo "ОШИБКА: Grafana не перечитала датасорсы ($GRAFANA_RELOAD) — правка provisioning/datasources осталась бы без эффекта." + exit 1 + ;; + esac + # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════ agent-apps: runs-on: ubuntu-latest @@ -561,6 +599,21 @@ jobs: [ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \ || { echo "::error::alloy читает старый инод конфига"; exit 1; } + # ── postgres-экспортёры: queries.yml — бинд-маунт ОДНОГО файла (#3486) ── + # PG_EXPORTER_EXTEND_QUERY_PATH читается только при старте, а + # `git reset --hard` пишет правку новым инодом: без пересоздания + # экспортёр продолжает отдавать старые запросы при зелёном деплое. + # Пересоздаём только при расхождении инода. Под гейтом профиля: + # `up` с явным именем сервиса включает его профиль сам. + if [ -n "$EXPORTER_PROFILE" ]; then + METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \ + postgres-exporter-gendesign ops/metrics/postgres/queries.yml /etc/pg-queries.yml + METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \ + postgres-exporter-tradein ops/metrics/postgres/queries.yml /etc/pg-queries.yml + fi + agent-infra: runs-on: ubuntu-latest needs: server @@ -630,3 +683,10 @@ jobs: [ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \ || { echo "::error::alloy читает старый инод конфига"; exit 1; } + + # queries.yml экспортёра инфраструктурной БД — см. agent-apps (#3486). + if [ -n "$EXPORTER_PROFILE" ]; then + METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + sh ops/metrics/recreate-stale-mount.sh gendesign-metrics-agent docker-compose.metrics-agent.yml \ + postgres-exporter-infra ops/metrics/postgres/queries.yml /etc/pg-queries.yml + fi diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7e2b4a2e..fb28e90c 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -77,7 +77,7 @@ on: # исполнял бы старую версию — молча и без единого сигнала. # Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт, # запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают - # backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное + # backup.sh, restore-drill.sh, restore.sh — точечное # перечисление пришлось бы дополнять при каждом новом скрипте, и про это # снова забыли бы (см. как этот самый комментарий выше был точечным про # docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком. diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index d9558635..50f73cce 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -1910,17 +1910,19 @@ def trigger_ekburg_permits( return {"task_id": result.id, "scope": scope, "queued_at": "now"} -# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24 -# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object -# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) — -# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession -# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1). -_WAF_COOLDOWN_GUARD_MSG = ( - "Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP " - "2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же " - "причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно " - "принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — " - "передай i_understand_waf_risk=true в теле запроса." +# Текст отказа guard'а блокировки DOM.РФ (#2443, #2445 D1). Зонды 20.08, 27.08 и +# 01.09: наш.дом.рф за StormWall отдаёт серверу «Доступ заблокирован [403]», таймера +# у этой блокировки нет. Beat-записи catalog-object и catalog-flat scrape'ов +# выключены по той же причине (см. beat_schedule.py); оба ad-hoc эндпоинта ниже +# ходят тем же /сервисы/* BrowserSession path family. Оператор решает по этому +# тексту, ставить ли флаг, поэтому он называет реальное условие (#3307). +_DOMRF_BLOCK_GUARD_MSG = ( + "Ad-hoc catalog-scrape заблокирован guard'ом: наш.дом.рф за StormWall отдаёт " + "этому серверу «Доступ заблокирован [403]» (зонды 20.08-01.09, issue #2443), " + "beat schedule для этого таска выключен по той же причине. Ожидание блокировку " + "не снимает, запуск отсюда даст только отказы. Передавай " + "i_understand_waf_risk=true, только когда сбор идёт через прокси и kn-прогон " + "принят по числу строк (#3307)." ) @@ -1952,20 +1954,20 @@ def trigger_kn_catalog_objects( """Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). - Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc - запуска (smoke-тест после деплоя или повторный pass для свежесозданных - объектов до next beat fire). + Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py). + Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для + свежесозданных объектов). - max_objects=None → дефолтный лимит таска (300). - max_objects=3 → smoke-тест. - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд. - WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc - re-trigger без явного подтверждения оператора запрещён. + Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc + запуск без явного подтверждения оператора запрещён (условие — #3307). """ if not payload.i_understand_waf_risk: - raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG) + raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG) from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects @@ -2022,12 +2024,12 @@ def trigger_kn_catalog_flats( - max_flats=3 → smoke-тест. - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. - WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger - того же WAF-бана. + Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же + блокировкой StormWall (условие — #3307). """ if not payload.i_understand_waf_risk: - raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG) + raise HTTPException(status_code=400, detail=_DOMRF_BLOCK_GUARD_MSG) from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats diff --git a/backend/app/services/scrapers/izyatie_ocr.py b/backend/app/services/scrapers/izyatie_ocr.py index 5e7b5e67..41cf33c1 100644 --- a/backend/app/services/scrapers/izyatie_ocr.py +++ b/backend/app/services/scrapers/izyatie_ocr.py @@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н # 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр. _RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b") -# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП». -_RE_ACT_NUMBER = re.compile( - r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b", - re.UNICODE | re.IGNORECASE, -) +# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон +# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга +# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не +# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12». +_ACT_NUM = r"(\d+(?:\s*[-–]\s*[А-Я]{1,3})?)(?![\w/\-–])" +_RE_ACT_NUMBER = re.compile(rf"№\s*{_ACT_NUM}") +# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY». +_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}") +_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"№\s*{_ACT_NUM}\s*$") _RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})") # Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не @@ -233,7 +237,9 @@ def extract_izyatie_records( normalized = _normalize_ocr_text(ocr_text) # Реквизиты акта из заголовка или текста. - act_number = _extract_act_number(doc_title) or _extract_act_number(normalized) + act_number = _extract_act_number(doc_title) or _extract_act_number( + normalized, require_act_context=True + ) act_date = _extract_act_date(doc_title) or _extract_act_date( normalized, require_act_context=True ) @@ -289,9 +295,26 @@ def extract_izyatie_records( # ── Вспомогательные функции ──────────────────────────────────────────────────── -def _extract_act_number(text: str) -> str | None: - """Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»).""" - m = _RE_ACT_NUMBER.search(text) +def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None: + """Извлекает номер акта. + + Без require_act_context (заголовок) — первое «№ N» в тексте. + + С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату + выбирает `_extract_act_date`: «№» вплотную после даты, иначе вплотную перед + ней. Первое «№» в теле — это «Решение Думы № 60/1» (Генплан) или «Приказ + Министерства № 746-П», а не основание изъятия. Номера рядом с датой нет — + None, а не номер соседнего документа. + """ + if not require_act_context: + m = _RE_ACT_NUMBER.search(text) + else: + date_m = _act_date_match(text, require_act_context=True) + if date_m is None: + return None + m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or ( + _RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start()) + ) if not m: return None return re.sub(r"\s+", "", m.group(1)) @@ -303,6 +326,17 @@ def _act_context_matches(text: str, pos: int) -> bool: return all(word in ctx for word in _ACT_CONTEXT_WORDS) +def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None: + """Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен).""" + for m in _RE_ACT_DATE.finditer(text): + if require_act_context and not _act_context_matches(text, m.start()): + continue + d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) + if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100: + return m + return None + + def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None: """Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE. @@ -315,18 +349,10 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать актуальные изъятия, ни сверить срок. """ - for m in _RE_ACT_DATE.finditer(text): - if require_act_context and not _act_context_matches(text, m.start()): - continue - day, month, year = m.group(1), m.group(2), m.group(3) - try: - # Валидируем диапазоны. - d, mo, y = int(day), int(month), int(year) - except ValueError: - continue - if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100: - return f"{y:04d}-{mo:02d}-{d:02d}" - return None + m = _act_date_match(text, require_act_context=require_act_context) + if m is None: + return None + return f"{m.group(3)}-{m.group(2)}-{m.group(1)}" def _extract_purpose(text: str) -> str | None: diff --git a/backend/app/services/scrapers/page_reservation_parser.py b/backend/app/services/scrapers/page_reservation_parser.py index 96ab63e7..1af12ae7 100644 --- a/backend/app/services/scrapers/page_reservation_parser.py +++ b/backend/app/services/scrapers/page_reservation_parser.py @@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b") # Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п. # Паттерн намеренно широкий — постановления РФ имеют разные суффиксы. +# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»), +# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий +# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает +# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ», +# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60». _RE_ACT_NUMBER = re.compile( - r"№\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b", + r"№\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])", re.UNICODE, ) diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index b0cce749..35ebe25f 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -346,11 +346,13 @@ def build_beat_schedule() -> dict: # Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта # за ~5 недель полного обновления. # - # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed - # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession - # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет - # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после - # cooldown 24-48h (проверить через targeted test). + # DISABLED с 2026-05-24. Исходная причина «hard-ban, cooldown 24-48h» оказалась + # догадкой: kn-прогоны 29-33 (03.06-28.06) шли успешно уже после 24.05. Зонды + # 20.08, 27.08 и 01.09 (#2443, #3307): наш.дом.рф стоит за StormWall (куки + # spid/spjs/spsc), IP Poincare получает «Доступ заблокирован [403]» — это + # блокировка, ожидание её не снимает. Каталог не собирался с 19.05. + # Включать только после решения по прокси и принятого по числу строк + # kn-прогона (#3307). Выключенность держит test_beat_schedule_domrf_catalog.py. # schedule["scrape-kn-catalog-objects-weekly"] = { # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", # "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК @@ -362,11 +364,11 @@ def build_beat_schedule() -> dict: # (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из # SSR-страниц каталога. Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. # - # DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP — - # каталог-квартиры используют тот же BrowserSession + /сервисы/* paths. + # DISABLED (как и object-level выше): 1) та же блокировка StormWall на IP + # Poincare — каталог-квартиры используют тот же BrowserSession + /сервисы/* paths. # 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и # свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет. - # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом). + # Включать после прокси и принятого kn-прогона с hash (#3307), не по таймеру. # Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку — # напр. четверг 04:00 МСК. # schedule["scrape-kn-catalog-flats-weekly"] = { diff --git a/backend/app/workers/tasks/izyatie_ocr_ingest.py b/backend/app/workers/tasks/izyatie_ocr_ingest.py index 0a65e9be..45d88d56 100644 --- a/backend/app/workers/tasks/izyatie_ocr_ingest.py +++ b/backend/app/workers/tasks/izyatie_ocr_ingest.py @@ -110,6 +110,25 @@ _UPSERT_NO_ACT_SQL = text( ) +# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982). +# +# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая +# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке +# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон +# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие +# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит +# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же +# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается. +_DELETE_STALE_PARSE_SQL = text( + """ + DELETE FROM land_reservation + WHERE cad_num = CAST(:cad_num AS text) + AND doc_url = CAST(:doc_url AS text) + AND act_number IS DISTINCT FROM CAST(:act_number AS text) + """ +) + + def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int: """UPSERT записей в land_reservation. Возвращает число успешно обработанных строк.""" count = 0 @@ -117,6 +136,7 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int: upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL try: with db.begin_nested(): # SAVEPOINT per-row + db.execute(_DELETE_STALE_PARSE_SQL, row) db.execute(upsert_sql, row) count += 1 except Exception as exc: diff --git a/backend/app/workers/tasks/scrape_kn_catalog_flats.py b/backend/app/workers/tasks/scrape_kn_catalog_flats.py index 0e30d1c6..756e666f 100644 --- a/backend/app/workers/tasks/scrape_kn_catalog_flats.py +++ b/backend/app/workers/tasks/scrape_kn_catalog_flats.py @@ -24,8 +24,9 @@ Sequencing dependency (важно): эта задача НЕ дублирует 1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И 2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах. До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен -ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на -VPS IP; включается вручную после проверки targeted-тестом. +ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level): наш.дом.рф за +StormWall отдаёт «Доступ заблокирован [403]» (#2443), ожидание это не снимает; +включать после прокси и принятого kn-прогона (#3307). """ from __future__ import annotations @@ -116,7 +117,7 @@ def scrape_kn_catalog_flats( Concurrency: No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.). - Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне + Beat отключён (блокировка StormWall, #2443); при ручном/ad-hoc запуске пересечение крайне маловероятно. Если случится: - UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id). - Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch. diff --git a/backend/app/workers/tasks/scrape_kn_catalog_objects.py b/backend/app/workers/tasks/scrape_kn_catalog_objects.py index 40d5b786..89ad9bd1 100644 --- a/backend/app/workers/tasks/scrape_kn_catalog_objects.py +++ b/backend/app/workers/tasks/scrape_kn_catalog_objects.py @@ -9,7 +9,7 @@ Selector logic: - DATE(catalog_scraped_at) = CURRENT_DATE — уже сегодня обновлён, пропускаем - force=True — игнорирует фильтр, загружает все объекты последнего snapshot -Beat schedule: вторник 04:00 UTC (в beat_schedule.py). +Beat-запись (вторник 04:00 МСК) выключена в beat_schedule.py: блокировка StormWall (#2443). """ from __future__ import annotations diff --git a/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py b/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py index db72ae82..643b786e 100644 --- a/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py +++ b/backend/tests/api/v1/test_admin_scrape_kn_catalog_waf_guard.py @@ -1,10 +1,11 @@ -"""Тесты для WAF cooldown guard (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах. +"""Тесты guard'а блокировки DOM.РФ (#2443, #2445 D1) на ad-hoc catalog-scrape эндпоинтах. POST /api/v1/admin/scrape/kn-catalog-objects и /kn-catalog-flats бьют по тому же -/сервисы/* BrowserSession path family, что вызвал DOM.РФ WAF hard-ban 2026-05-24 -(#2443). Beat schedule для обоих тасков отключён по этой причине — эти эндпоинты -должны отказывать в запуске БЕЗ явного i_understand_waf_risk=true, чтобы случайный -smoke-тест/re-trigger не углубил бан. +/сервисы/* BrowserSession path family, что закрыт StormWall (#2443). Beat schedule +для обоих тасков отключён по этой причине — эти эндпоинты должны отказывать в +запуске БЕЗ явного i_understand_waf_risk=true. Текст отказа — то, по чему оператор +решает, ставить ли флаг, поэтому он обязан называть реальное условие (#3307), а не +ожидание таймера: зонды 20.08-01.09 показали, что ждать нечего. """ from __future__ import annotations @@ -19,6 +20,15 @@ OBJECTS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-objects" FLATS_ENDPOINT = "/api/v1/admin/scrape/kn-catalog-flats" +def _assert_refusal_names_real_condition(detail: str) -> None: + assert "2443" in detail + assert "i_understand_waf_risk" in detail + # Условие снятия — прокси и принятый kn-прогон (#3307). Обещание «cooldown + # прошёл» подсказывало оператору обойти блокировку, которую ожидание не снимает. + assert "3307" in detail + assert "cooldown" not in detail.lower() + + def _mock_apply_async_result(task_id: str = "fake-task-id") -> MagicMock: result = MagicMock() result.id = task_id @@ -33,9 +43,7 @@ def test_kn_catalog_objects_refuses_without_override() -> None: resp = TestClient(app).post(OBJECTS_ENDPOINT, json={"region_code": 66}) assert resp.status_code == 400 - detail = resp.json()["detail"] - assert "2443" in detail - assert "i_understand_waf_risk" in detail + _assert_refusal_names_real_condition(resp.json()["detail"]) mock_apply_async.assert_not_called() @@ -68,9 +76,7 @@ def test_kn_catalog_flats_refuses_without_override() -> None: resp = TestClient(app).post(FLATS_ENDPOINT, json={"region_code": 66}) assert resp.status_code == 400 - detail = resp.json()["detail"] - assert "2443" in detail - assert "i_understand_waf_risk" in detail + _assert_refusal_names_real_condition(resp.json()["detail"]) mock_apply_async.assert_not_called() diff --git a/backend/tests/ops/test_2203_notify_topic.py b/backend/tests/ops/test_2203_notify_topic.py index cad0f108..68557413 100644 --- a/backend/tests/ops/test_2203_notify_topic.py +++ b/backend/tests/ops/test_2203_notify_topic.py @@ -1,4 +1,4 @@ -"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078). +"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164). Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в @@ -16,9 +16,7 @@ реально ушло бы в сеть. Функция извлекается из файла построчно, а не копируется в тест: копия разошлась -бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у -`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы -настоящие сетевые проверки. +бы с оригиналом на первой же правке. """ from __future__ import annotations @@ -34,7 +32,6 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[3] SENDERS = { "lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh", - "uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh", } FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n' @@ -105,7 +102,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str] f'ARGS_DUMP="{_posix(dump)}"', 'TELEGRAM_BOT_TOKEN="123:FAKE"', 'TELEGRAM_CHAT_ID="-1004443088679"', - f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"", + f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"', ] if topic is not None: prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"') @@ -153,3 +150,73 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None: args = _run_notify(tmp_path, SENDERS[name], topic="158") assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id" assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст" + + +# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245), +# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт +# только в env-файле на хосте, поэтому образец — единственное место в репозитории, +# откуда следующий, кто заводит хост, узнает правильный адрес. +INFRA_TOPIC = "245" +BACKUP_ENV_EXAMPLES = [ + "gendesign-backup.default.example", + "gendesign-backup-couchdb.default.example", + "gendesign-backup-forgejo.default.example", +] + + +@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES) +def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None: + """Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо. + + Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим + маркером, а env-файл — образец, заполненный так, как он велит человеку: + раскомментированы строки `TELEGRAM_*`, подставлены токен и чат. + + Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат. + `notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в + окружении, — тема из другого файла молча не подхватится. + """ + text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8") + filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text) + filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled) + filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled) + env_file = tmp_path / "backup.env" + env_file.write_text(filled, encoding="utf-8", newline="\n") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "curl" + fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n") + fake.chmod(0o755) + dump = tmp_path / "args.txt" + + # Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе + # перебили бы файл, и тест проверял бы не образец. + prefix = [ + f'PATH="{_posix(bin_dir)}:$PATH"', + f'ARGS_DUMP="{_posix(dump)}"', + 'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""', + f'BACKUP_ENV_FILE="{_posix(env_file)}"', + f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"', + ] + script = REPO_ROOT / "ops" / "check-backup-staleness.sh" + proc = subprocess.run( + [ + "bash", + "-c", + " ".join(prefix) + + f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"', + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=30, + env=dict(os.environ), + ) + args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else [] + assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}" + threads = [a for a in args if a.startswith("message_thread_id=")] + assert threads == [f"message_thread_id={INFRA_TOPIC}"], ( + f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, " + f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})" + ) diff --git a/backend/tests/ops/test_3059_alert_retry.py b/backend/tests/ops/test_3059_alert_retry.py index 071080c1..b2390eea 100644 --- a/backend/tests/ops/test_3059_alert_retry.py +++ b/backend/tests/ops/test_3059_alert_retry.py @@ -14,7 +14,9 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15 Как это ломало алерты: -* `ops/uptime-healthcheck.sh` — ОДИН `curl`, дальше `|| log WARN`. Каждый +* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был + запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) — ОДИН + `curl`, дальше `|| log WARN`. Каждый отказ терял уведомление целиком. Watchdog, который не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ @@ -51,7 +53,6 @@ import pytest # backend/tests/ops/<этот файл> → корень репозитория REPO_ROOT = Path(__file__).resolve().parents[3] -UPTIME = "ops/uptime-healthcheck.sh" LIB_BACKUP = "ops/lib-backup.sh" # См. подробное обоснование shutil.which в @@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за def _extract_function(script_rel: str, name: str = "notify") -> str: """Достаёт тело одной функции из скрипта — не весь файл. - Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально - ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh. + Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh. Сопоставление точное (`name() {`), иначе `notify` поймал бы `notify_fallback_mail` — соседнюю функцию в том же файле. @@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]: return calls, out, err -@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: """Один отказ — алерт всё равно доставляется со второй попытки. @@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным" -@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) def test_gives_up_after_three_attempts(script_rel: str) -> None: """Повторы ограничены: три попытки, а не бесконечный цикл. @@ -164,12 +164,6 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None: assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз" -def test_uptime_reports_undelivered_alert_loudly() -> None: - """Когда все три попытки провалились — это видно в логе, а не молча.""" - _calls, _out, err = _run_notify(UPTIME, fail_times=99) - assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}" - - def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None: """Транзиентный таймаут не должен трогать запасной канал. diff --git a/backend/tests/ops/test_3467_prometheus_reload.py b/backend/tests/ops/test_3467_prometheus_reload.py index d27e5673..554cbb22 100644 --- a/backend/tests/ops/test_3467_prometheus_reload.py +++ b/backend/tests/ops/test_3467_prometheus_reload.py @@ -10,28 +10,41 @@ docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не в Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`) и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py) -в этом же workflow — только для Prometheus починка не пересоздание -контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по -пути заново, так что новый инод после `git reset --hard` подхватывается -без даунтайма. +в этом же workflow. `POST /-/reload` лечит правила — они подключены +КАТАЛОГОМ. prometheus.yml же — бинд-маунт ОДНОГО файла: reload перечитывает +за тем же путём СТАРЫЙ инод (prom/prometheus:v3.1.0, 17.09: файл подменён, +reload rc=0, в /api/v1/status/config прежний job). Поэтому при расхождении +инода контейнер пересоздаётся (ops/metrics/recreate-stale-mount.sh, его +поведение — test_metrics_single_file_mounts.py), а promtool проверяет файлы С +ДИСКА: `docker exec` в работающий контейнер проверял бы старый инод. -Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается -ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не -подтверждён сменой lastConfigTime. +Проверяется здесь: (1) валидация promtool ЕСТЬ и идёт по файлам с диска, +(2) reload и пересоздание — ТОЛЬКО после успешной валидации, (3) шаг обязан +упасть, если reload не подтверждён сменой lastConfigTime, (4) датасорсы +Grafana перечитываются, и отказ этого шага роняет деплой. """ from __future__ import annotations +import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[3] WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" +CHECK_CONFIG = "promtool check config /etc/prometheus/prometheus.yml" def _text() -> str: return WORKFLOW.read_text(encoding="utf-8") +def _guard_start(text: str) -> int: + """Начало `if <проверка promtool>; then` — ближайший `if` перед check config.""" + guard = text.rfind("\n if ", 0, text.index(CHECK_CONFIG)) + assert guard != -1, "проверка promtool не стоит под `if` — reload ничем не защищён" + return guard + + def test_promtool_checks_config_and_rules() -> None: """promtool обязан проверять и конфиг, и правила — не только один файл.""" text = _text() @@ -65,7 +78,7 @@ def test_reload_happens_after_validation_not_before() -> None: def test_reload_is_guarded_by_the_promtool_check() -> None: """Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным.""" text = _text() - guard_start = text.index("if docker exec gendesign-prometheus promtool check config") + guard_start = _guard_start(text) else_pos = text.index("else", guard_start) reload_pos = text.index("localhost:9090/-/reload") assert guard_start < reload_pos < else_pos, ( @@ -77,7 +90,7 @@ def test_reload_is_guarded_by_the_promtool_check() -> None: def test_failed_validation_skips_reload_and_fails_the_step() -> None: """При провале promtool — reload НЕ вызывается, и шаг падает (exit 1).""" text = _text() - guard_start = text.index("if docker exec gendesign-prometheus promtool check config") + guard_start = _guard_start(text) else_pos = text.index("else", guard_start) fi_pos = text.index("fi", else_pos) else_branch = text[else_pos:fi_pos] @@ -110,3 +123,54 @@ def test_acceptance_checks_last_config_time_actually_changed() -> None: assert "exit 1" in verify_block, ( "если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным" ) + + +def test_promtool_checks_files_from_disk_not_the_running_container() -> None: + """Проверяется то, что сейчас применится, а не то, что держит контейнер. + + prometheus.yml смонтирован одним файлом: `docker exec … promtool check config` + в работающем контейнере читает СТАРЫЙ инод — битая правка прошла бы проверку + и уехала бы в пересоздание. + """ + text = _text() + guard = text[_guard_start(text) : text.index(CHECK_CONFIG)] + assert "docker run --rm" in guard, "promtool запускается не одноразовым контейнером" + assert "/opt/gendesign/ops/metrics/prometheus:/etc/prometheus:ro" in guard, ( + "одноразовый promtool смотрит не на файлы с диска по боевым путям" + ) + assert not re.search(r"docker exec gendesign-prometheus[^\n]*promtool", text), ( + "promtool всё ещё проверяет работающий контейнер — то есть старый инод" + ) + + +def test_stale_prometheus_yml_is_recreated_between_check_and_reload() -> None: + """Пересоздание по иноду — после проверки promtool и до reload.""" + text = _text() + check = text.index("promtool check config /etc/prometheus/prometheus.yml") + recreate = re.search( + r"recreate-stale-mount\.sh \\\s+gendesign-metrics docker-compose\.metrics\.yml \\\s+" + r"prometheus ops/metrics/prometheus/prometheus\.yml /etc/prometheus/prometheus\.yml", + text, + ) + assert recreate, "prometheus.yml не сверяется по иноду — правка конфига не доедет" + else_pos = text.index("else", _guard_start(text)) + assert check < recreate.start() < text.index("localhost:9090/-/reload") < else_pos, ( + "пересоздание Prometheus стоит не между успешной проверкой и reload" + ) + + +def test_grafana_datasources_are_reloaded_and_failure_is_red() -> None: + """Датасорсы Grafana применяются только при старте — их надо перечитать явно. + + Замер в PR #3475 (grafana:11.5.1): изменённый url не применился и через 75 с, + POST /api/admin/provisioning/datasources/reload применил сразу. Ответ ручки в + v11.5.1 — «Datasources config reloaded» (pkg/api/admin_provisioning.go). + """ + text = _text() + call = text.find("http://localhost:3000/api/admin/provisioning/datasources/reload") + assert call != -1, "деплой не перечитывает датасорсы Grafana" + block = re.search(r'case "\$GRAFANA_RELOAD" in(.+?)\besac\b', text[call:], re.S) + assert block, "результат перезагрузки датасорсов не разбирается" + ok, _, failed = block.group(1).partition(";;") + assert "Datasources config reloaded" in ok, "успех не сверяется по тексту ответа ручки" + assert "exit 1" in failed and "exit 1" not in ok, "отказ перезагрузки не роняет деплой" diff --git a/backend/tests/ops/test_metrics_single_file_mounts.py b/backend/tests/ops/test_metrics_single_file_mounts.py new file mode 100644 index 00000000..91511daf --- /dev/null +++ b/backend/tests/ops/test_metrics_single_file_mounts.py @@ -0,0 +1,310 @@ +"""Правка пофайлово смонтированного конфига метрик обязана доезжать до контейнера. + +ЧТО НЕ ТАК. `git reset --hard` пишет изменившийся файл НОВЫМ инодом, а bind-маунт +одного файла держит прежний. `up -d` этого не видит (сравнивает описание сервиса), +перезагрузка по API тоже: на prom/prometheus:v3.1.0 (17.09) prometheus.yml подменён, +POST /-/reload → rc=0, lastConfigTime новый, а /api/v1/status/config отдаёт прежний +job. Деплой зелёный, конфиг старый — у отказа нет отрицательного признака (#3467). +Так смонтированы prometheus.yml и loki-config.yml на сервере и queries.yml у трёх +postgres-экспортёров (#3486). + +ЧТО ПРОВЕРЯЕТСЯ. +1. ops/metrics/recreate-stale-mount.sh ИСПОЛНЯЕТСЯ с подставным `docker`: смотрим + на совершённые действия (пересоздал / не тронул / упал), а не на текст. +2. Гейт по compose-файлам метрик: у КАЖДОГО пофайлового маунта есть путь доезда в + той джобе деплоя, что поднимает сервис. Список маунтов берётся ИЗ compose: + добавят новый — тест покраснеет, пока путь не появится. +""" + +from __future__ import annotations + +import os +import re +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "ops" / "metrics" / "recreate-stale-mount.sh" +WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" + +SRC = "ops/metrics/loki/loki-config.yml" +DST = "/etc/loki/loki-config.yml" + +# Подставной `docker`. Каждый вызов пишется в $FAKE_LOG. +# compose … ps -q — id контейнера из $FAKE_CID_FILE (пусто = не запущен); +# код из $FAKE_PS_RC; +# compose … up … — «пересоздание»: новый id cid-new, и контейнер видит +# инод $FAKE_NEW_VIEW (пусто — не видит ничего); +# exec ID stat -c %i … — инод, который видит контейнер ID ($FAKE_VIEW/ID). +FAKE_DOCKER = r"""#!/bin/bash +printf '%s\n' "$*" >> "$FAKE_LOG" +cmd="$1"; shift +case "$cmd" in + exec) + view="$FAKE_VIEW/$1" + [ -f "$view" ] || exit 1 + cat "$view" + ;; + compose) + case " $* " in + *" ps "*) + [ "${FAKE_PS_RC:-0}" = "0" ] || { echo "compose error" >&2; exit "$FAKE_PS_RC"; } + cat "$FAKE_CID_FILE" + ;; + *" up "*) + printf 'cid-new\n' > "$FAKE_CID_FILE" + [ -z "${FAKE_NEW_VIEW:-}" ] || printf '%s\n' "$FAKE_NEW_VIEW" > "$FAKE_VIEW/cid-new" + ;; + esac + ;; +esac +exit 0 +""" + +# На раннере и на хостах stat — GNU/busybox (`-c %i`); на macOS — BSD (`-f %i`). +# Шим ставится только там, где `-c` не понимают, иначе гейт не запускался бы локально. +STAT_SHIM = ( + '#!/bin/sh\n[ "$1" = "-c" ] && [ "$2" = "%i" ] && exec /usr/bin/stat -f %i "$3"\n' + 'exec /usr/bin/stat "$@"\n' +) +NO_SLEEP = "#!/bin/sh\nexit 0\n" + + +def _write_exec(path: Path, text: str) -> None: + path.write_text(text, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _gnu_stat() -> bool: + return subprocess.run(["stat", "-c", "%i", "/"], capture_output=True).returncode == 0 + + +@pytest.fixture +def host(tmp_path: Path) -> Path: + """Боевое дерево: скрипт + конфиг; контейнер cid-old видит ТЕКУЩИЙ инод.""" + tree = tmp_path / "opt" / "gendesign" + (tree / "ops" / "metrics" / "loki").mkdir(parents=True) + shutil.copy(SCRIPT, tree / "ops" / "metrics" / SCRIPT.name) + (tree / SRC).write_text("auth_enabled: false\n", encoding="utf-8") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + _write_exec(bin_dir / "docker", FAKE_DOCKER) + _write_exec(bin_dir / "sleep", NO_SLEEP) + if not _gnu_stat(): + _write_exec(bin_dir / "stat", STAT_SHIM) + + view = tmp_path / "view" + view.mkdir() + (view / "cid-old").write_text(f"{_ino(tree)}\n", encoding="utf-8") + (tmp_path / "cid").write_text("cid-old\n", encoding="utf-8") + (tmp_path / "log").write_text("", encoding="utf-8") + return tree + + +def _ino(tree: Path) -> int: + return os.stat(tree / SRC).st_ino + + +def _stale(tree: Path) -> None: + """Контейнер держит прежний инод — как после `git reset --hard`.""" + (tree.parent.parent / "view" / "cid-old").write_text(f"{_ino(tree) + 1}\n", encoding="utf-8") + + +def _run(tree: Path, **env_extra: str) -> tuple[int, str, list[str]]: + root = tree.parent.parent + env = { + "PATH": f"{root / 'bin'}:/usr/bin:/bin:/usr/sbin:/sbin", + "FAKE_LOG": str(root / "log"), + "FAKE_CID_FILE": str(root / "cid"), + "FAKE_VIEW": str(root / "view"), + **env_extra, + } + proc = subprocess.run( + [ + "sh", + str(tree / "ops" / "metrics" / SCRIPT.name), + "gendesign-metrics", + "docker-compose.metrics.yml", + "loki", + SRC, + DST, + ], + cwd="/", # скрипт обязан сам встать в корень дерева + env=env, + capture_output=True, + text=True, + ) + calls = [c for c in (root / "log").read_text(encoding="utf-8").splitlines() if c] + return proc.returncode, proc.stdout + proc.stderr, calls + + +def _recreates(calls: list[str]) -> list[str]: + return [c for c in calls if "--force-recreate" in c] + + +# ── Что скрипт делает на самом деле ────────────────────────────────────────── + + +def test_fresh_inode_touches_nothing(host: Path) -> None: + """Контейнер видит текущий файл — пересоздавать нельзя: это рвёт работу зря.""" + rc, out, calls = _run(host) + assert rc == 0, out + assert not _recreates(calls), f"пересоздан без расхождения инода: {calls}" + + +def test_stale_inode_recreates_that_service_and_confirms(host: Path) -> None: + """Старый инод → пересоздать ИМЕННО этот сервис и убедиться, что доехало.""" + _stale(host) + rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host))) + assert rc == 0, out + recreates = _recreates(calls) + assert len(recreates) == 1, f"ожидалось одно пересоздание, было: {calls}" + assert recreates[0].split()[-1] == "loki", f"пересоздан не тот сервис: {recreates[0]}" + assert "--no-deps" in recreates[0], f"пересоздание тянет зависимости: {recreates[0]}" + assert "-p gendesign-metrics -f docker-compose.metrics.yml" in recreates[0], recreates[0] + assert any(c.startswith("exec cid-new ") for c in calls), ( + f"после пересоздания инод НОВОГО контейнера не сверен: {calls}" + ) + + +def test_still_stale_after_recreate_fails_the_deploy(host: Path) -> None: + """Пересоздание не помогло — красный деплой, а не зелёный со старым конфигом.""" + _stale(host) + rc, out, _calls = _run(host, FAKE_NEW_VIEW=str(_ino(host) + 1)) + assert rc != 0, f"контейнер всё ещё на старом иноде, а скрипт вышел с 0:\n{out}" + assert SRC in out, f"в логе не назван файл, который не доехал:\n{out}" + + +def test_unreadable_container_view_counts_as_stale(host: Path) -> None: + """Инод в контейнере не прочитался — это расхождение, а не «доехало».""" + (host.parent.parent / "view" / "cid-old").unlink() + rc, out, calls = _run(host, FAKE_NEW_VIEW=str(_ino(host))) + assert rc == 0, out + assert _recreates(calls), f"непрочитанный инод сочтён доехавшим: {calls}" + + +def test_not_running_container_is_left_alone(host: Path) -> None: + """Контейнера нет — старый инод держать некому; поднимать сервис тут нельзя.""" + (host.parent.parent / "cid").write_text("", encoding="utf-8") + rc, out, calls = _run(host) + assert rc == 0, out + assert not _recreates(calls), f"поднят незапущенный сервис: {calls}" + + +def test_compose_failure_is_not_read_as_not_running(host: Path) -> None: + """Упавший `compose ps` — не «контейнер не запущен»: иначе зелёный выход вслепую.""" + _stale(host) + rc, out, calls = _run(host, FAKE_PS_RC="1") + assert rc != 0, f"ошибка compose проглочена, скрипт вышел с 0:\n{out}" + assert not _recreates(calls), calls + + +def test_missing_host_file_is_not_success(host: Path) -> None: + """Файла на хосте нет — сверять не с чем; молча выйти с 0 нельзя.""" + (host / SRC).unlink() + rc, out, _calls = _run(host) + assert rc != 0, f"отсутствующий файл сочтён доехавшим:\n{out}" + + +# ── Гейт: у каждого пофайлового маунта есть путь доезда ────────────────────── + +# Производные файлы: их рендерит сам деплой, в git их нет. +# alertmanager.yml — rm + рендер, пересоздание по ALERTMANAGER_RERENDERED; +# alertmanager_targets.gen.yml — усечение на месте (`: >`), инод сохраняется. +RENDERED = { + "ops/metrics/alertmanager/alertmanager.yml", + "ops/metrics/prometheus/alertmanager_targets.gen.yml", +} +# Источник маунта: ./путь[:${VAR:?…}]:/куда[:ro]. `${…}` внутри источника — файл, +# выбираемый ролью (alloy-apps.alloy / alloy-infra.alloy). +MOUNT = re.compile(r"^\./(?P(?:\$\{[^}]*\}|[^:])+):(?P/[^:]+)") +COMPOSE_FILES = { + "docker-compose.metrics.yml": "gendesign-metrics", + "docker-compose.metrics-agent.yml": "gendesign-metrics-agent", +} + + +def _jobs_for(compose_name: str, profiles: list[str]) -> list[str]: + """Джобы деплоя, поднимающие сервис: сервер — одна; агенты — по профилю.""" + if compose_name == "docker-compose.metrics.yml": + return ["server"] + return [f"agent-{p}" for p in profiles] or ["agent-apps", "agent-infra"] + + +def _commands(job: str) -> str: + """ssh-скрипт джобы: комментарии выкинуты, продолжения строк склеены. + + Комментарии — потому что разбор дефекта содержит его формулировку дословно. + """ + spec = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + steps = [s for s in spec["jobs"][job]["steps"] if "ssh-action" in str(s.get("uses"))] + assert len(steps) == 1, f"в джобе {job} не ровно один ssh-шаг — гейт ослеп" + kept = [ln for ln in steps[0]["with"]["script"].splitlines() if not ln.lstrip().startswith("#")] + return re.sub(r"\\\n\s*", " ", "\n".join(kept)) + + +def _file_mounts(compose_name: str) -> list[tuple[str, str, str, list[str]]]: + spec = yaml.safe_load((REPO_ROOT / compose_name).read_text(encoding="utf-8")) + found = [] + for service, body in spec["services"].items(): + for vol in body.get("volumes") or []: + m = MOUNT.match(vol) if isinstance(vol, str) else None + if not m or (REPO_ROOT / m["src"]).is_dir() or m["src"] in RENDERED: + continue + found.append((service, m["src"], m["dst"], body.get("profiles") or [])) + return found + + +def _arrival(commands: str, project: str, compose_name: str, service: str, src: str, dst: str): + script_call = re.search( + rf"recreate-stale-mount\.sh\s+{re.escape(project)}\s+{re.escape(compose_name)}" + rf"\s+{re.escape(service)}\s+{re.escape(src)}\s+{re.escape(dst)}[^\n]*", + commands, + ) + forced = re.search( + rf"--force-recreate(?:\s+--no-deps)?\s+{re.escape(service)}(?![\w-])[^\n]*", commands + ) + return script_call or forced + + +@pytest.mark.parametrize("compose_name", sorted(COMPOSE_FILES)) +def test_every_single_file_mount_has_a_way_to_arrive(compose_name: str) -> None: + project = COMPOSE_FILES[compose_name] + mounts = _file_mounts(compose_name) + assert mounts, f"в {compose_name} не найдено ни одного пофайлового маунта — маска ослепла" + missing = [] + for service, src, dst, profiles in mounts: + for job in _jobs_for(compose_name, profiles): + hit = _arrival(_commands(job), project, compose_name, service, src, dst) + if not hit: + missing.append(f"{job}: {service} {src} → {dst}") + elif re.search(r"\|\|\s*(true|:)\s*$", hit.group(0)): + missing.append(f"{job}: {service} — отказ проглочен: {hit.group(0).strip()}") + assert not missing, ( + "пофайловые маунты без пути доезда: правка файла ляжет на диск, контейнер " + "останется на старом иноде, деплой зелёный:\n " + "\n ".join(missing) + ) + + +def test_gate_sees_the_known_mounts() -> None: + """Проверка на себя: маска обязана находить маунты, ради которых гейт заведён.""" + server = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics.yml")} + assert ("prometheus", "ops/metrics/prometheus/prometheus.yml") in server, server + assert ("loki", "ops/metrics/loki/loki-config.yml") in server, server + agent = {(s, src) for s, src, _d, _p in _file_mounts("docker-compose.metrics-agent.yml")} + for exporter in ("gendesign", "tradein", "infra"): + assert (f"postgres-exporter-{exporter}", "ops/metrics/postgres/queries.yml") in agent, agent + + +def test_gate_would_notice_a_mount_without_arrival() -> None: + """Сервис без пути доезда гейт обязан НЕ найти — иначе он зелёный по построению.""" + commands = _commands("server") + assert not _arrival( + commands, "gendesign-metrics", "docker-compose.metrics.yml", "nosuch", SRC, DST + ), "гейт нашёл путь доезда у несуществующего сервиса — маска слишком широкая" diff --git a/backend/tests/services/scrapers/test_2982_act_number.py b/backend/tests/services/scrapers/test_2982_act_number.py new file mode 100644 index 00000000..a13eed07 --- /dev/null +++ b/backend/tests/services/scrapers/test_2982_act_number.py @@ -0,0 +1,103 @@ +"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982). + +Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У +постановлений Администрации города его нет, поэтому на проде `act_number` пуст у +всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0). + +Фрагменты — дословный OCR пяти прод-документов из test_2464_act_date_citation +(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера — из самих PDF: +1413, 259, 2687, 863, 1504. Помехи в тех же текстах — «Решение Думы № 60/1» +(Генплан) и «Приказ Министерства № 746-П»: их номер браться не должен. + +Тест зовёт `extract_izyatie_records` — путь загрузчика — и краснеет на origin/main +неверным значением (None вместо номера), а не ошибкой сигнатуры. +""" + +from __future__ import annotations + +import pytest + +from app.services.scrapers.izyatie_ocr import extract_izyatie_records +from app.services.scrapers.page_reservation_parser import extract_reservations +from tests.services.scrapers.test_2464_act_date_citation import ( + ДОК_ПЕРЕМЕШАННЫЙ, + ДОК_ПЕРЕМЕШАННЫЙ_2, + ДОК_ПРЯМОЙ, + ДОК_РАЗВЯЗКА, + ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, + ЗАГОЛОВОК, + КАД, +) + +ПЯТЬ_ДОКУМЕНТОВ = [ + pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"), + pytest.param(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"), + pytest.param(ДОК_ПЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"), + pytest.param(ДОК_ПЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"), + pytest.param(ДОК_ПРЯМОЙ, "1504", "2019-06-24", id="татищева"), +] + + +def _запись(текст: str) -> dict: + записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y") + assert записи, "кад-номер не разобран — тест смотрел бы не туда" + return записи[0] + + +@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬ_ДОКУМЕНТОВ) +def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None: + """Головной: номер постановления без суффикса извлекается, и это номер того же акта, + чья дата стоит в act_date, — а не Решения Думы или приказа Министерства.""" + запись = _запись(текст) + assert запись["act_number"] == номер + assert запись["act_date"] == дата + + +@pytest.mark.parametrize(("текст", "номер", "_дата"), ПЯТЬ_ДОКУМЕНТОВ) +def test_page_parser_takes_the_same_numbers(текст: str, номер: str, _дата: str) -> None: + """Второе место того же дефекта — page_reservation_parser (pravo.gov66). + + Там номер — первое «№» в тексте. В двух документах первыми идут «№ 60/1» и + «№ 746-П»: их нельзя ни взять, ни обрезать до «60»/«746». + """ + assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер + + +def test_citations_alone_give_no_number() -> None: + """Контроль: без постановления — None, а не номер Генплана или приказа.""" + текст = ( + "Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n" + "Приказ Министерства строительства и развития инфраструктуры Свердловской " + "области от 30.12.2021 № 746-П" + ) + assert _запись(текст)["act_number"] is None + assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None + + +def test_number_belongs_to_the_act_whose_date_is_taken() -> None: + """Контроль связки: номер берётся у того же акта, что и дата, а не первое «№». + + Тот же документ «развязка», но у приказа Министерства номер без суффикса. + Первое подходящее «№» в тексте — «746», дата при этом у постановления № 1413: + в строке оказалась бы пара реквизитов от двух разных документов. + """ + текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746") + запись = _запись(текст) + assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413") + + +def test_page_parser_prefers_own_number_over_cited_regional_act() -> None: + """Контроль порядка: собственный номер в шапке без суффикса не пропускается ради + следующего «№ …-ПП» — номера цитируемого постановления Правительства.""" + текст = ( + "ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n" + "В соответствии с постановлением Правительства Свердловской области " + f"от 26.02.2020 № 100-ПП\n{КАД}" + ) + assert extract_reservations(текст)[0].act_number == "1234" + + +def test_regional_suffix_still_extracted() -> None: + """Контроль от переусердствования: областной номер с суффиксом не потерян.""" + assert _запись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП" + assert _запись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП" diff --git a/backend/tests/sql/test_2982_land_reservation_reparse.py b/backend/tests/sql/test_2982_land_reservation_reparse.py new file mode 100644 index 00000000..23be005c --- /dev/null +++ b/backend/tests/sql/test_2982_land_reservation_reparse.py @@ -0,0 +1,134 @@ +"""Починка разбора номера не удваивает land_reservation (#2982). + +act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На +проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться, +строка (участок, «1413») с (участок, NULL) не конфликтует — без очистки прогон +кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды. + +Проверяется `_upsert_records` загрузчика целиком на временной таблице той же +формы: боевые данные не читаются и не меняются. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from app.workers.tasks.izyatie_ocr_ingest import _upsert_records + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_TABLE = """ +CREATE TEMP TABLE land_reservation ( + id bigserial PRIMARY KEY, + cad_num text NOT NULL, + reservation_kind text NOT NULL, + basis_act text NOT NULL, + act_number text, + act_date date, + purpose text, + doc_url text, + source text NOT NULL DEFAULT 'page_pdf', + is_active boolean NOT NULL DEFAULT true, + raw_excerpt text, + fetched_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number) +) ON COMMIT DROP; +""" + +КАД = "66:41:0303004:22" +ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8" + + +def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict: + return { + "cad_num": cad, + "reservation_kind": "изъятие", + "basis_act": "Сообщение о планируемом изъятии", + "act_number": act, + "act_date": "2022-05-27", + "purpose": None, + "doc_url": url, + "source": "izyatie_ekb_ocr", + "raw_excerpt": None, + } + + +@pytest.fixture +def db(): + engine = create_engine(_dsn()) + session = sessionmaker(bind=engine)() + try: + session.execute(text(_TABLE)) + n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar() + assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)" + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +def _rows(db) -> list[tuple[str, str | None, str]]: + return [ + (r[0], r[1], r[2]) + for r in db.execute( + text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3") + ).all() + ] + + +def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None: + """Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером.""" + _upsert_records(db, [_row(act=None)]) + assert _rows(db) == [(КАД, None, ДОК)] + + assert _upsert_records(db, [_row(act="1413")]) == 1 + assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой" + + _upsert_records(db, [_row(act="1413")]) + assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен" + + +def test_same_parcel_in_another_document_is_kept(db) -> None: + """Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем.""" + другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5" + _upsert_records(db, [_row(act="259", url=другой)]) + _upsert_records(db, [_row(act=None)]) + _upsert_records(db, [_row(act="1413")]) + assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)] + + +def test_other_parcels_of_same_document_are_kept(db) -> None: + """Контроль: чистка идёт по участку, а не по всему документу.""" + _upsert_records(db, [_row(cad="66:41:0303004:23", act=None)]) + _upsert_records(db, [_row(act="1413")]) + assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)] diff --git a/backend/tests/workers/test_beat_schedule_domrf_catalog.py b/backend/tests/workers/test_beat_schedule_domrf_catalog.py new file mode 100644 index 00000000..d2a2b954 --- /dev/null +++ b/backend/tests/workers/test_beat_schedule_domrf_catalog.py @@ -0,0 +1,38 @@ +"""#2443: каталожные DOM.РФ-скраперы не должны попасть в beat, пока стоит блокировка. + +С 01.09 наш.дом.рф за StormWall отдаёт IP Poincare «Доступ заблокирован [403]». +Решение «выключено до прокси» раньше жило только в комментарии с обещанием +«cooldown 24-48h» и месяцами читалось как таймер. Здесь оно проверяется по +значению: включить запись = осознанно поправить этот тест после #3307. +""" + +from __future__ import annotations + +from unittest.mock import patch + +_BLOCKED_TASKS = ( + "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", + "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats", +) + + +def test_domrf_catalog_scrapers_not_scheduled_while_stormwall_blocks() -> None: + from app.workers import beat_schedule + + with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}): + schedule = beat_schedule.build_beat_schedule() + + # Контроль: статическая часть расписания реально построилась, иначе + # отсутствие задач ниже было бы зелёным по построению. + assert schedule["refresh-quarter-price-index"]["task"] == ( + "tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain" + ) + + # По task, а не по ключу: переименованный ключ не должен обойти проверку. + scheduled = { + name: entry["task"] for name, entry in schedule.items() if entry["task"] in _BLOCKED_TASKS + } + assert scheduled == {}, ( + f"каталог DOM.РФ включён в beat: {scheduled}. IP Poincare заблокирован " + "StormWall (#2443); включать только после прокси и принятого kn-прогона (#3307)" + ) diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 2f7dd5e7..cd8eab1e 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -1919,17 +1919,17 @@ export interface paths { * @description Manual trigger для catalog-OBJECT scraper (заполняет wall_type, energy_eff, * ceiling_height_m, parking_*, playground_*, scores из SSR __NEXT_DATA__). * - * Beat schedule: Tuesday 04:00 UTC, batch 300/run. Этот endpoint — для ad-hoc - * запуска (smoke-тест после деплоя или повторный pass для свежесозданных - * объектов до next beat fire). + * Beat-запись выключена (блокировка StormWall, #2443; см. beat_schedule.py). + * Этот endpoint — для ad-hoc запуска (smoke-тест или повторный pass для + * свежесозданных объектов). * * - max_objects=None → дефолтный лимит таска (300). * - max_objects=3 → smoke-тест. * - force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд. * - * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - * beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc - * re-trigger без явного подтверждения оператора запрещён. + * Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + * beat schedule для этого таска выключен из-за блокировки StormWall, ad-hoc + * запуск без явного подтверждения оператора запрещён (условие — #3307). */ post: operations["trigger_kn_catalog_objects_api_v1_admin_scrape_kn_catalog_objects_post"]; delete?: never; @@ -1960,9 +1960,9 @@ export interface paths { * - max_flats=3 → smoke-тест. * - force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash. * - * WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true — - * same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger - * того же WAF-бана. + * Guard блокировки DOM.РФ (#2443, #2445 D1): требует i_understand_waf_risk=true — + * same /сервисы/* BrowserSession path family как catalog-objects, закрыт той же + * блокировкой StormWall (условие — #3307). */ post: operations["trigger_kn_catalog_flats_api_v1_admin_scrape_kn_catalog_flats_post"]; delete?: never; diff --git a/ops/check-backup-staleness.sh b/ops/check-backup-staleness.sh index d21d041a..c3fdec35 100755 --- a/ops/check-backup-staleness.sh +++ b/ops/check-backup-staleness.sh @@ -10,9 +10,8 @@ # script says so loudly instead of the silence that let backups break for # weeks undetected before (#71). # -# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same -# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam -# Telegram once a backup is already known to be stale. +# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an +# hourly cron doesn't spam Telegram once a backup is already known to be stale. # # Usage (cron — one line per sentinel, run more often than the backup itself # so a stale state is caught promptly; hourly is a reasonable default for a @@ -27,9 +26,10 @@ # /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \ # >> /var/log/gendesign-backup-staleness.log 2>&1 # -# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see -# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup, -# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only. +# Alert channel: notify() in ops/lib-backup.sh — creds in +# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/ +# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first +# two, logs only. # # Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be # used as a plain healthcheck by anything that just wants the exit code). diff --git a/ops/crontab-beget.cron b/ops/crontab-beget.cron index 16639595..617a00aa 100644 --- a/ops/crontab-beget.cron +++ b/ops/crontab-beget.cron @@ -22,7 +22,11 @@ 15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1 # Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч. -0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 +# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env +# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без +# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на +# этом хосте нет, — алерты молча не уходят никуда. +0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 # ── Бэкап волта Obsidian (CouchDB) ────────────────────────────────────────── # Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись @@ -33,11 +37,11 @@ # file-level копия работающего узла может оказаться рассогласованной. Время — # 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс), # чтобы три задачи не дрались за диск и сеть одновременно. -45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 +45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 # Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных: # сутки плюс запас на сдвиг прогона. -0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 +0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 # ── Уборка докера ─────────────────────────────────────────────────────────── # Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их diff --git a/ops/gendesign-backup-couchdb.default.example b/ops/gendesign-backup-couchdb.default.example index 81bccbcd..1766b312 100644 --- a/ops/gendesign-backup-couchdb.default.example +++ b/ops/gendesign-backup-couchdb.default.example @@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY= # Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат: #TELEGRAM_BOT_TOKEN= #TELEGRAM_CHAT_ID= +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 # Либо укажи другой файл, откуда notify() возьмёт настройки канала: #BACKUP_ENV_FILE=/etc/default/gendesign-backup diff --git a/ops/gendesign-backup-forgejo.default.example b/ops/gendesign-backup-forgejo.default.example index 78e9d4e5..b24dc34f 100644 --- a/ops/gendesign-backup-forgejo.default.example +++ b/ops/gendesign-backup-forgejo.default.example @@ -27,6 +27,17 @@ #FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED #FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED +# --- оповещения (Telegram) --- +# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без +# токена и чата notify() ищет их в /etc/default/gendesign-backup. +#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN +#TELEGRAM_CHAT_ID=123456789 +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 + # --- optional overrides (defaults are sensible; uncomment only to change) --- #FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match # the policy's Resource path exactly diff --git a/ops/gendesign-backup.default.example b/ops/gendesign-backup.default.example index ee6da0ad..a01c754f 100644 --- a/ops/gendesign-backup.default.example +++ b/ops/gendesign-backup.default.example @@ -24,13 +24,8 @@ # # Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron # entry, see its header) alerts when a backup's sentinel goes stale. It reads -# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from -# /etc/default/gendesign-uptime — deliberately a separate config so backup -# alerting doesn't depend on the uptime watchdog's env file existing). -# SAME variable names as ops/gendesign-uptime.default.example — point both -# files at the same bot/chat if you want one Telegram destination for -# everything, or use different bots/chats if you'd rather split the noise. -# Without these two set, ops/check-backup-staleness.sh still logs, just +# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file. +# Without the first two set, ops/check-backup-staleness.sh still logs, just # doesn't send a Telegram alert. # --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) — @@ -41,9 +36,14 @@ #S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY #S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY -# --- missed-run alerting (Telegram, shared bot with uptime watchdog) --- +# --- missed-run alerting (Telegram) --- #TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN #TELEGRAM_CHAT_ID=123456789 +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 # --- optional overrides (defaults are sensible; uncomment only to change) --- #BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and diff --git a/ops/gendesign-uptime.default.example b/ops/gendesign-uptime.default.example deleted file mode 100644 index db99c75f..00000000 --- a/ops/gendesign-uptime.default.example +++ /dev/null @@ -1,28 +0,0 @@ -# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75). -# -# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host -# runs the cron (ideally a host OTHER than the prod VM, so it survives a full -# VPS outage): -# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime -# sudo chmod 600 /etc/default/gendesign-uptime -# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds -# -# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set, -# it still logs up/down but sends no alert (useful for a dry run first). -# -# Get a bot token from @BotFather; get your chat_id by messaging the bot then -# GET https://api.telegram.org/bot/getUpdates and reading message.chat.id. - -# --- Telegram alerting (both required to enable alerts) --- -#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN -#TELEGRAM_CHAT_ID=123456789 - -# --- optional overrides (defaults are sensible; uncomment only to change) --- -#BASE_URL=https://gendsgn.ru -#STATE_FILE=/var/tmp/gendesign-uptime-state -#CURL_TIMEOUT=15 -#RETRIES=2 -#RETRY_SLEEP=5 -# Custom check list (newline-separated "label|path|expected_status"): -#CHECKS="health|/health|200 -#market-pulse|/api/v1/analytics/market-pulse|200" diff --git a/ops/lib-backup.sh b/ops/lib-backup.sh index 417bfe3f..2d38960a 100755 --- a/ops/lib-backup.sh +++ b/ops/lib-backup.sh @@ -5,9 +5,7 @@ # SOURCED, not executed directly — no shebang execution of its own. Inherits # the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins + # coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by -# notify() when Telegram vars are actually set — curl is already a hard -# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no -# new dependency). +# notify() when Telegram vars are actually set). # # Load with (script computes its own dir so this works regardless of cron's # CWD or which repo subdir the caller lives in): @@ -18,14 +16,8 @@ log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } # --- notify ------------------------------------------------------------ -# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) — -# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN / -# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file -# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so -# backup alerting doesn't depend on the uptime watchdog's config being -# present, and vice versa. Point both files at the same bot/chat if you want -# one Telegram destination for everything — that's an ops choice, not this -# script's concern. +# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env +# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164. # # No-op (logs only) when unset — this is the extension point: to wire a # different channel later, edit ONLY this function; every caller in this repo @@ -153,12 +145,9 @@ sentinel_age_hours() { } # --- transition-tracked alert state -------------------------------------- -# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a -# flat "