chore(tradein/proxy): выпилить мёртвые узлы mobileproxy и их ключи из пула (#2613)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
This commit is contained in:
parent
887df54908
commit
779b57e1b3
2 changed files with 189 additions and 0 deletions
|
|
@ -0,0 +1,83 @@
|
|||
-- 201_purge_dead_mobileproxy_proxies.sql
|
||||
-- Issue #2613 — выпилить мёртвые узлы mobileproxy из пула scrape_proxies
|
||||
-- вместе с чужим API-ключом, который лежал у них в rotate_url.
|
||||
--
|
||||
-- WHY:
|
||||
-- Владелец подтвердил: подписка mobileproxy закрыта, продлевать не будут.
|
||||
-- Прямая проба каждого узла из контейнера tradein-scraper (2026-08-01)
|
||||
-- подтверждает смерть: id 2 — connection refused, id 3/4/5 — 407 Proxy
|
||||
-- Authentication Required. Последняя успешная проверка (last_check_at) у
|
||||
-- всех четырёх — 4-9 июля, все четыре enabled=false, consecutive_fails=5.
|
||||
--
|
||||
-- Две причины удалить, вторая важнее:
|
||||
-- 1. Мёртвые узлы засоряют пул и его health-метрики.
|
||||
-- 2. rotate_url у трёх из четырёх строк (id 3, 4, 5) хранит открытым
|
||||
-- текстом чужой ключ провайдера в query-параметре ссылки ротации
|
||||
-- (https://changeip.mobileproxy.space/?proxy_key=...). Именно из-за
|
||||
-- неоднородности этой колонки (вперемешку с ASocks-строками, где
|
||||
-- rotate_url — наш собственный API-эндпоинт БЕЗ секрета в URL,
|
||||
-- авторизация Bearer-заголовком) глубокое ревью PR #2611 нашло
|
||||
-- блокер: вызов ротации для такой строки отправил бы НАШ токен
|
||||
-- ASocks на changeip.mobileproxy.space. Пин хоста в #2611 уже
|
||||
-- закрывает саму уязвимость, но чужой секрет в базе держать незачем.
|
||||
--
|
||||
-- ПОЧЕМУ DELETE, А НЕ UPDATE (очистка полей + enabled=false):
|
||||
-- Единственный FK, ссылающийся на scrape_proxies — scrape_proxy_rotations
|
||||
-- .proxy_id (заведён 198_scrape_proxy_rotations.sql), delete_rule NO ACTION.
|
||||
-- На момент миграции (замер ниже) в scrape_proxy_rotations нет НИ ОДНОЙ
|
||||
-- строки вообще — таблица введена в этом же цикле работ (#2600 п.5) и
|
||||
-- ручная ротация ни разу не запускалась. DELETE четырёх строк scrape_proxies
|
||||
-- ничего не упирает. Если бы к строкам 2-5 успела прилипнуть история ротаций
|
||||
-- к моменту применения — DELETE упадёт по FK-violation ВНУТРИ этой же
|
||||
-- транзакции (BEGIN/COMMIT ниже), миграция целиком откатится, deploy
|
||||
-- завершится ошибкой (auto-apply strict, exit 1) без частичного эффекта и
|
||||
-- без порчи данных; отдельного ON DELETE-обработчика не требуется — узлы
|
||||
-- disabled=false уже сейчас, acquire() их не выдаёт (idx_scrape_proxies_pick
|
||||
-- фильтрует по enabled), новых ротаций на них взяться неоткуда до deploy.
|
||||
-- Строки — исторический мусор без ссылок, полное удаление честнее частичной
|
||||
-- очистки (не оставляет призрачную запись мёртвого узла в пуле) и убирает
|
||||
-- секрет из базы целиком, а не только из одной колонки.
|
||||
--
|
||||
-- Matching (по домену url, НЕ по id):
|
||||
-- id в scrape_proxies разъезжается между средами (bulk-загрузка независима
|
||||
-- per-среда, тот же класс проблемы решён в 199 через host:port-matching).
|
||||
-- Условие — WHERE url LIKE '%mobileproxy.space%' — ловит все четыре узла
|
||||
-- независимо от порта/поддомена (ha./gi./auv./aup.mobileproxy.space) и не
|
||||
-- заденет ASocks-строки (212.8.249.134 / 190.2.145.131 / 175.110.115.153 /
|
||||
-- 109.236.82.42 — IP-адреса, без mobileproxy.space в url вовсе).
|
||||
--
|
||||
-- ЗАМЕРЫ (SELECT, read-only, прод, перед миграцией, 2026-08-01):
|
||||
-- Строк под условие (url LIKE '%mobileproxy.space%'): 4 (id 2, 3, 4, 5)
|
||||
-- Остаток пула после удаления (url NOT LIKE '%mobileproxy.space%'):
|
||||
-- 4 (id 1, 9, 10, 11) — все ASocks
|
||||
-- Строк в scrape_proxy_rotations на id 2/3/4/5: 0
|
||||
-- Строк в scrape_proxy_rotations всего (таблица пуста): 0
|
||||
-- Секрет-паттерн (token|bearer|secret|key=|password, regex
|
||||
-- case-insensitive) в rotate_url ОСТАЮЩИХСЯ 4 строк: 0 совпадений
|
||||
-- (rotate_url остающихся — https://api.asocks.com/unlimited-proxy/
|
||||
-- <portId>/refresh-ip, без query-параметров вообще, авторизация Bearer
|
||||
-- заголовком вне URL, см. 199_scrape_proxies_asocks_rotate_url.sql)
|
||||
-- FK, ссылающиеся на scrape_proxies: ровно один —
|
||||
-- scrape_proxy_rotations.proxy_id -> scrape_proxies.id, delete_rule NO ACTION.
|
||||
--
|
||||
-- Idempotency:
|
||||
-- Обычный DELETE ... WHERE — повторный прогон находит 0 строк (уже
|
||||
-- удалены), no-op. Весь файл в BEGIN/COMMIT.
|
||||
--
|
||||
-- ГРАНИЦЫ: НЕ трогает ASocks-строки (id 1, 9, 10, 11) и их rotate_url. НЕ
|
||||
-- трогает переменные окружения (*_PROXY_URL, BROWSER_PROXY_*,
|
||||
-- *_PROXY_ROTATE_URL) — их снятие отдельная задача и НЕ раньше неё, иначе
|
||||
-- при пустом прокси curl_proxy_url отдаёт None = скрапер идёт напрямую с IP
|
||||
-- сервера. НЕ трогает app/services/proxy_pool.py, proxy_rotation.py,
|
||||
-- скраперы. Никакого DDL.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- 157_scrape_proxies.sql (scrape_proxies.url/rotate_url/enabled).
|
||||
-- 198_scrape_proxy_rotations.sql (FK proxy_id -> scrape_proxies.id, NO ACTION).
|
||||
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM scrape_proxies
|
||||
WHERE url LIKE '%mobileproxy.space%';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
"""Static guards for migration 201 (issue #2613 — выпилить мёртвые узлы
|
||||
mobileproxy из scrape_proxies вместе с чужим API-ключом в rotate_url).
|
||||
|
||||
Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон
|
||||
требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ
|
||||
идемпотентность, domain-based scope (НЕ по id — они разъезжаются между
|
||||
средами) и то, что ASocks-строки миграция не задевает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_201 = _SQL_DIR / "201_purge_dead_mobileproxy_proxies.sql"
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
return _MIGRATION_201.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _executable_sql() -> str:
|
||||
"""SQL без построчных `--`-комментариев — только исполняемый код."""
|
||||
lines = []
|
||||
for raw in _sql().splitlines():
|
||||
code = raw.split("--", 1)[0]
|
||||
if code.strip():
|
||||
lines.append(code)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _flat(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip().lower()
|
||||
|
||||
|
||||
def test_migration_201_exists() -> None:
|
||||
assert _MIGRATION_201.exists(), f"missing migration: {_MIGRATION_201}"
|
||||
|
||||
|
||||
def test_migration_201_is_transactional() -> None:
|
||||
sql = _sql()
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_201_deletes_by_domain_not_id() -> None:
|
||||
"""Условие удаления — по домену mobileproxy.space в url, НЕ по id (id
|
||||
разъезжается между средами, тот же класс проблемы решён в 199 через
|
||||
host:port-matching)."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert "delete from scrape_proxies" in flat
|
||||
assert "where url like '%mobileproxy.space%'" in flat
|
||||
assert (
|
||||
re.search(r"where\s+id\s*(=|in)", flat) is None
|
||||
), "миграция не должна фильтровать по id — id разъезжается между средами"
|
||||
|
||||
|
||||
def test_migration_201_is_idempotent_by_construction() -> None:
|
||||
"""DELETE ... WHERE без вспомогательного флага — повторный прогон
|
||||
находит 0 строк (уже удалены в первом прогоне), сам DELETE идемпотентен
|
||||
по построению, отдельного guard-условия не требуется."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert flat.count("delete from") == 1
|
||||
assert "delete from scrape_proxies" in flat
|
||||
|
||||
|
||||
def test_migration_201_does_not_touch_asocks_rows() -> None:
|
||||
"""ASocks-строки (id 1, 9, 10, 11) адресуются IP-хостами
|
||||
(212.8.249.134 / 190.2.145.131 / 175.110.115.153 / 109.236.82.42) без
|
||||
mobileproxy.space в url — WHERE их не задевает. Явно запрещаем regression
|
||||
в сторону id-based или asocks-упоминающего условия."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert "asocks" not in flat
|
||||
for asocks_ip in (
|
||||
"212.8.249.134",
|
||||
"190.2.145.131",
|
||||
"175.110.115.153",
|
||||
"109.236.82.42",
|
||||
):
|
||||
assert asocks_ip not in flat
|
||||
|
||||
|
||||
def test_migration_201_no_ddl() -> None:
|
||||
"""Только DELETE данных — никакого ALTER/CREATE/DROP TABLE/TRUNCATE."""
|
||||
flat = _flat(_executable_sql())
|
||||
assert "alter table" not in flat
|
||||
assert "create table" not in flat
|
||||
assert "drop table" not in flat
|
||||
assert "truncate" not in flat
|
||||
|
||||
|
||||
def test_migration_201_no_secret_literal_in_file() -> None:
|
||||
"""Файл миграции не должен содержать сам секрет (query-параметр
|
||||
proxy_key mobileproxy.space) — только описание проблемы текстом."""
|
||||
sql = _sql()
|
||||
assert "proxy_key=" not in sql or "proxy_key=..." in sql or "<секрет>" in sql
|
||||
# Явный запрет на длинные alnum-токены рядом с 'proxy_key=' (сам секрет).
|
||||
assert not re.search(r"proxy_key=[A-Za-z0-9_-]{10,}", sql)
|
||||
|
||||
|
||||
def test_migration_201_no_psycopg_trap() -> None:
|
||||
"""Никаких :param::type — psycopg v3 требует CAST(... AS type) (не
|
||||
применимо в чистом .sql без bind params, но проверяем на регресс
|
||||
copy-paste из Python-кода)."""
|
||||
assert not re.search(r":\w+::", _sql())
|
||||
Loading…
Add table
Reference in a new issue