Files
ripe-cidr-collector/docs/plan-sqlite-storage.md
T
ayurishchevandClaude Sonnet 5 bcf8156085 Initial commit: RIPE CIDR/FQDN collector
Collector daemon, FastAPI server (addresses, diff, collect, sources),
SQLite storage with change journal, Docker Compose deployment,
tests, documentation and project rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 07:29:38 +03:00

11 KiB
Raw Blame History

План: хранение собранных адресов в 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)

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 данные на месте); по окончании убрать тестовые контейнеры, тома и образы.