72 lines
11 KiB
Markdown
72 lines
11 KiB
Markdown
# План: хранение собранных адресов в SQLite
|
||||
|
|
|
|||
|
|
## Context
|
|||
|
|
Собранные адреса хранятся в `data.json` и `fqdn_data.json`: каждая запись целиком читается и переписывается, доступ между процессами (демон, API, CLI) защищён файловыми блокировками, а повреждение файла ведёт к потере накопленной истории. SQLite даёт транзакции, конкурентное чтение во время записи, запросы по адресам и основу для будущих diff и истории (`first_seen`/`last_seen` уже есть). API, форматы вывода и семантика TTL не меняются.
|
|||
|
|
|
|||
|
|
Границы: в SQLite переезжают только собранные адреса. `config.json` (источники, расписание, `ttl_days`) и `status.json` (heartbeat) остаются JSON: их редактируют вручную, а демон опрашивает конфиг. Резервное копирование, diff и уведомления в этот шаг не входят.
|
|||
|
|
|
|||
|
|
## Артефакты по правилам проекта (создаются при реализации)
|
|||
|
|
- `docs/plan-sqlite-storage.md` - копия этого плана (первым шагом)
|
|||
|
|
- `docs/summary-sqlite-storage.md` - итоги (в конце)
|
|||
|
|
- обновить `README.md` (схема хранения, миграция, откат)
|
|||
|
|
|
|||
|
|
## Схема (`ripe.db` в `DATA_DIR`)
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE addresses (
|
|||
|
|
kind TEXT NOT NULL CHECK (kind IN ('asn', 'fqdn')),
|
|||
|
|
source TEXT NOT NULL, -- '62041' или 'example.com'
|
|||
|
|
value TEXT NOT NULL, -- префикс или IP
|
|||
|
|
first_seen TEXT NOT NULL, -- ISO-время
|
|||
|
|
last_seen TEXT NOT NULL,
|
|||
|
|
PRIMARY KEY (kind, source, value)
|
|||
|
|
);
|
|||
|
|
CREATE INDEX addresses_value ON addresses (value);
|
|||
|
|
PRAGMA user_version = 1; -- версия схемы для будущих миграций
|
|||
|
|
```
|
|||
|
|
Режим `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`, `temp_store=MEMORY`. Время хранится строками ISO (секундная точность для новых записей), сравнение `last_seen < cutoff` корректно лексикографически.
|
|||
|
|
|
|||
|
|
## Изменения
|
|||
|
|
|
|||
|
|
### 1. Новый модуль `db.py`
|
|||
|
|
- `connect(path=None)`: открывает базу (`cc.DB_FILE`), применяет PRAGMA, при необходимости создаёт схему и **однократно импортирует старые JSON** (см. ниже). Ошибки уровня `sqlite3.DatabaseError`, кроме `OperationalError` (например, «database is locked»), считаются порчей: файл переименовывается в `ripe.db.corrupt-<ts>` (вместе с `-wal`/`-shm`), поднимается `StorageError` (API -> 503, демон записывает `last_error`) - то же поведение, что было для битого JSON.
|
|||
|
|
- `merge_source(conn, kind, source, values, now, ttl_days)`: в одной транзакции upsert найденных значений (`ON CONFLICT DO UPDATE SET last_seen`), затем `DELETE ... WHERE kind=? AND source=? AND last_seen < cutoff` (просроченные, которых сегодня не видели; при `ttl_days = 0` не удаляется ничего). Возвращает добавленные и удалённые значения для лога. Заменяет `merge_entry` и правило «обновлять last_seen раз в сутки» (запись в SQLite дешёвая, обновляем всегда).
|
|||
|
|
- `sweep_unconfigured(conn, kind, configured, now, ttl_days)`: удаляет просроченные значения источников, которых нет в конфигурации (одним `DELETE ... source NOT IN (...)`); пустые записи как сущность больше не нужны.
|
|||
|
|
- `purge_source(conn, kind, source)`, `get_values(conn, kind=None)` (`SELECT DISTINCT value`), `count_values(conn, kind)`.
|
|||
|
|
|
|||
|
|
### 2. Миграция старых данных (внутри `connect`)
|
|||
|
|
- Условие: `user_version = 0` и таблицы нет. Под `BEGIN IMMEDIATE` (API и демон могут стартовать одновременно; второй ждёт и видит уже выполненную миграцию).
|
|||
|
|
- Импорт `data.json` (kind `asn`) и `fqdn_data.json` (kind `fqdn`): значения с блоком `seen` сохраняют `first_seen`/`last_seen`; записи старого формата без `seen` (реальные данные сейчас именно такие) получают `first_seen = last_updated`, `last_seen = сейчас` (как в прежней миграции: TTL идёт с момента перехода). Сразу пишется `user_version = 1`.
|
|||
|
|
- После успешной транзакции JSON-файлы переименовываются в `data.json.migrated-<ts>` и `fqdn_data.json.migrated-<ts>` (не удаляются - это и есть резервная копия и путь отката).
|
|||
|
|
- Импорт идемпотентен: повторный запуск (`user_version = 1`) ничего не читает.
|
|||
|
|
|
|||
|
|
### 3. `cidr_collector.py` (упрощается)
|
|||
|
|
- Константа `DB_FILE = os.path.join(DATA_DIR, "ripe.db")`; `DATA_FILE` и `FQDN_DATA_FILE` остаются только как пути для импорта старых данных.
|
|||
|
|
- `CIDRCollector`/`FQDNCollector.run_collection`: сетевая часть без изменений; затем `with db.connect() as conn` (одна транзакция): читает конфиг, для найденных источников вызывает `db.merge_source`, затем `db.sweep_unconfigured`. Источник, удалённый во время сбора, по-прежнему пропускается (проверка по конфигу внутри транзакции).
|
|||
|
|
- Удаляются `load_data`/`save_data`, `merge_entry`, `sweep_unconfigured` (JSON-версия), `purge_entry`, `LAST_SEEN_REFRESH`, блокировки `file_lock(DATA_FILE)`. Блокировка конфига (`update_config`) остаётся.
|
|||
|
|
|
|||
|
|
### 4. `api_server.py`
|
|||
|
|
- `get_cidrs()`/`get_fqdn_ips()` и счётчики `/health` читают через `db.get_values`/`db.count_values` (соединение на запрос).
|
|||
|
|
- `DELETE /asns|/fqdns ?purge=true` вызывает `db.purge_source`.
|
|||
|
|
- `StorageError` от `db.connect` обрабатывается существующим обработчиком (503).
|
|||
|
|
|
|||
|
|
### 5. Развёртывание
|
|||
|
|
- `Dockerfile`: добавить `db.py` в список копируемых файлов (иначе образ не соберёт рабочее приложение).
|
|||
|
|
- `.gitignore`/`.dockerignore`: `*.db`, `*.db-wal`, `*.db-shm`, `*.migrated-*`.
|
|||
|
|
- Docker: оба сервиса используют одну базу в томе `/data` (WAL работает между контейнерами на одном хосте с локальным томом; не рекомендуется на сетевых ФС - отметить в README). `requirements.txt` не меняется (`sqlite3` из стандартной библиотеки; нужна SQLite >= 3.24 для upsert, в `python:3.11-slim` и на текущем хосте выполняется).
|
|||
|
|
|
|||
|
|
### 6. Документация (`README.md`)
|
|||
|
|
Раздел о хранении: таблица `addresses`, что хранится в JSON, а что в БД, автоматическая миграция, **откат** (остановить сервисы, вернуть `*.migrated-*` в `data.json`/`fqdn_data.json`, запустить прежнюю версию; адреса, собранные после миграции, при откате будут потеряны), просмотр данных (`sqlite3 ripe.db "SELECT ..."`), примечание про сетевые ФС; обновить описание «Collector Logic» и переносимость данных в раздел Docker (миграция теперь копирует и `ripe.db`).
|
|||
|
|
|
|||
|
|
### 7. Тесты (минимум)
|
|||
|
|
Существующие тесты, использовавшие `data.json`, переводятся на БД (подмена `cc.DB_FILE` на временный файл, заполнение через `db.merge_source`): TTL и безопасность при сбое (`test_core`), форматы API (`test_formats`), purge и старение удалённого источника (`test_sources_api`); `test_daemon` не затрагивается. Добавляется 1 тест миграции: JSON в старом формате (без `seen`) и в новом (с `seen`) импортируются с ожидаемыми `first_seen`/`last_seen`, файлы переименованы, повторное открытие ничего не меняет. Итого 14 тестов.
|
|||
|
|
|
|||
|
|
## Критичные файлы
|
|||
|
|
Новый `db.py`; правки `cidr_collector.py`, `api_server.py`, `Dockerfile`, `README.md`, `.gitignore`, `.dockerignore`, `tests/test_core.py`, `tests/test_formats.py`, `tests/test_sources_api.py`. Переиспользуем: `StorageError` (`storage.py`), `update_config`, `load_full_config`, `formatters.build_output` (без изменений: принимает множество значений).
|
|||
|
|
|
|||
|
|
## Проверка
|
|||
|
|
1. `docker build -f Dockerfile.test -t ripe-collector-test . && docker run --rm ripe-collector-test` - 14 тестов.
|
|||
|
|
2. **Эталонное сравнение**: на копии реальных данных (`scratchpad/backup`) запустить API после миграции; ответы `/addresses?type=all|cidr|fqdn` побайтно совпадают с эталонами `before_*.txt`, снятыми старой версией; JSON-файлы переименованы, повторный запуск не импортирует заново.
|
|||
|
|
3. Сбор на копии: демон/CLI `run` создаёт и обновляет строки; повторный запуск не плодит дубликатов; уменьшенный `ttl_days` удаляет просроченное; `purge=true` удаляет адреса источника.
|
|||
|
|
4. Конкурентность: во время идущего сбора цикл запросов `curl /addresses` не даёт ошибок (WAL: чтение не блокируется записью); одновременный старт API и демона на пустой базе выполняет миграцию один раз.
|
|||
|
|
5. Порча: записать мусор в `ripe.db` - API отвечает 503, файл переименован в `*.corrupt-*`, демон фиксирует ошибку.
|
|||
|
|
6. Docker Compose на копии данных (как в прошлый раз, отдельный проект): оба контейнера работают с одной базой в томе (`healthy`, API видит адреса, собранные демоном, после `down`/`up` данные на месте); по окончании убрать тестовые контейнеры, тома и образы.
|