Withhold addresses after the database is recreated without a backup

When ripe.db is corrupted and no valid backup exists, a new empty database
is created with a db_recreated.json marker; /addresses and /addresses/diff
answer 503 until the collector gathers data again, /health reports
db_recreated. Tests now isolate all state files via conftest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-21 09:56:43 +03:00
1 parent 46d56654d8
commit 0155fd3f38
13 files changed
+217 -23

No files matched your search

+1
View File
@@ -17,3 +17,4 @@ collect_request.json
graphify-out/ graphify-out/
backups/ backups/
last_restore.json last_restore.json
db_recreated.json
+1
View File
@@ -16,3 +16,4 @@ data.json
fqdn_data.json fqdn_data.json
backups/ backups/
last_restore.json last_restore.json
db_recreated.json
+2 -2
View File
@@ -396,7 +396,7 @@ Body is optional: `type` is `asn`, `fqdn` or `all` (default). The API does not c
### Endpoint: Health ### Endpoint: Health
**GET** `/health` **GET** `/health`
Reports the state of the collector daemon (read from `status.json`): `collector_alive`, the cron / `running` / last run / `last_finished` / last error / next run of each job, the number of stored addresses and `last_restore` (`null`, or the record of the last automatic restore of the database from a backup, see section 9). The daemon writes a heartbeat every 30 seconds; `collector_alive` is `false` if it is older than 120 seconds or the daemon never ran. `status` is `ok` only if the daemon is alive and no job failed in its last run; otherwise `degraded` (HTTP code is still 200). Reports the state of the collector daemon (read from `status.json`): `collector_alive`, the cron / `running` / last run / `last_finished` / last error / next run of each job, the number of stored addresses and `last_restore` (`null`, or the record of the last automatic restore of the database from a backup) and `db_recreated` (`null`, or `{at, pending}` after the database was recreated without a backup; `pending: true` makes `status` `degraded`), see section 9. The daemon writes a heartbeat every 30 seconds; `collector_alive` is `false` if it is older than 120 seconds or the daemon never ran. `status` is `ok` only if the daemon is alive and no job failed in its last run; otherwise `degraded` (HTTP code is still 200).
--- ---
@@ -524,7 +524,7 @@ Stop both services, rename the `*.migrated-*` files back to `data.json` / `fqdn_
Copies are named `ripe-<UTC time>.db` and stored in `RIPE_BACKUP_DIR` (default `<RIPE_DATA_DIR>/backups`, i.e. `/data/backups` in Docker). **By default they are on the same volume as the database**: this protects against a corrupted file, not against losing the volume. For that, mount a separate volume/host directory and set `RIPE_BACKUP_DIR` to it, or copy the directory elsewhere regularly (e.g. `docker compose cp collector:/data/backups ./backups`). Copies are named `ripe-<UTC time>.db` and stored in `RIPE_BACKUP_DIR` (default `<RIPE_DATA_DIR>/backups`, i.e. `/data/backups` in Docker). **By default they are on the same volume as the database**: this protects against a corrupted file, not against losing the volume. For that, mount a separate volume/host directory and set `RIPE_BACKUP_DIR` to it, or copy the directory elsewhere regularly (e.g. `docker compose cp collector:/data/backups ./backups`).
**Automatic restore.** If `ripe.db` cannot be opened as a database (any process: API, daemon, CLI), the file is moved to `ripe.db.corrupt-<timestamp>` and the newest copy that passes the integrity check is put in its place; concurrent processes are serialized with a lock. The event is logged (ERROR), written to `last_restore.json` and shown in `GET /health` as `last_restore`. Data collected after that copy is lost; the collector gathers it again on the next runs. If there is no valid copy, the behaviour is as before: the API answers `503`. **Automatic restore.** If `ripe.db` cannot be opened as a database (any process: API, daemon, CLI), the file is moved to `ripe.db.corrupt-<timestamp>` and the newest copy that passes the integrity check is put in its place; concurrent processes are serialized with a lock. The event is logged (ERROR), written to `last_restore.json` and shown in `GET /health` as `last_restore`. Data collected after that copy is lost; the collector gathers it again on the next runs. If there is no valid copy, a new empty database is created and the marker file `db_recreated.json` is written. **While the data is not gathered again, `GET /addresses` and `GET /addresses/diff` answer `503` with `Retry-After: 300`** instead of an empty list (a consumer could take it for the truth and wipe its rules); a type without configured sources (e.g. no FQDNs) is not blocked. The collector removes the marker after a run when every configured type has data again; to accept an empty result earlier, delete `db_recreated.json` by hand. `GET /health` shows `degraded` and `db_recreated` (`at`, `pending`) meanwhile. A fresh installation (no database yet) and a manually deleted database file are not treated as a loss and return an empty list until the first collection.
- The change journal goes back with the copy, so after a restore all cursors and times issued earlier answer `410` on `/addresses/diff` (the client makes a full download). The journal counter is shifted by 1,000,000 for that (a heuristic: it assumes fewer changes than that between two copies). - The change journal goes back with the copy, so after a restore all cursors and times issued earlier answer `410` on `/addresses/diff` (the client makes a full download). The journal counter is shifted by 1,000,000 for that (a heuristic: it assumes fewer changes than that between two copies).
- Only corruption detected **when the database is opened** is restored automatically. Damage inside the file shows up as `503` on reads and is caught by the `backup` job (`quick_check`); restore it by hand: stop both services, keep the damaged `ripe.db*`, copy the chosen `backups/ripe-*.db` to `ripe.db`, start the services. - Only corruption detected **when the database is opened** is restored automatically. Damage inside the file shows up as `503` on reads and is caught by the `backup` job (`quick_check`); restore it by hand: stop both services, keep the damaged `ripe.db*`, copy the chosen `backups/ripe-*.db` to `ripe.db`, start the services.
+25 -3
View File
@@ -108,6 +108,19 @@ def verify_token(x_api_key: Optional[str] = Header(None)):
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key.") raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key.")
def kinds_of(address_type):
"""Типы данных в базе для значения параметра type."""
return {"asn" if t == AddressType.cidr else "fqdn"
for t in (AddressType.cidr, AddressType.fqdn) if address_type in (t, AddressType.all_types)}
def ensure_data_ready(conn, kinds):
"""503, пока база пересоздана после порчи и данные не собраны заново: пустой список ввёл бы потребителя в заблуждение."""
if db.recreated_pending(conn, kinds):
raise HTTPException(status_code=503, headers={"Retry-After": "300"},
detail="The database was recreated after corruption; data is being collected again.")
def get_cidrs() -> List[str]: def get_cidrs() -> List[str]:
with db.session() as conn: with db.session() as conn:
return db.get_values(conn, "asn") return db.get_values(conn, "asn")
@@ -133,6 +146,7 @@ def get_addresses(
): ):
# Курсор читаем до данных: изменения между чтением курсора и данных повторятся в diff, что безвредно # Курсор читаем до данных: изменения между чтением курсора и данных повторятся в diff, что безвредно
with db.session() as conn: with db.session() as conn:
ensure_data_ready(conn, kinds_of(type))
headers = {"X-Changes-Cursor": str(db.journal_head(conn))} headers = {"X-Changes-Cursor": str(db.journal_head(conn))}
results = set() results = set()
@@ -175,9 +189,9 @@ def get_addresses_diff(
ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"), ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"),
): ):
cursor, since_ts = parse_since(since) cursor, since_ts = parse_since(since)
kinds = {"asn" if t == AddressType.cidr else "fqdn" kinds = kinds_of(type)
for t in (AddressType.cidr, AddressType.fqdn) if type in (t, AddressType.all_types)}
with db.session() as conn: with db.session() as conn:
ensure_data_ready(conn, kinds)
changes = db.get_changes(conn, kinds, cursor, since_ts) changes = db.get_changes(conn, kinds, cursor, since_ts)
if changes is None: if changes is None:
raise HTTPException(status_code=410, detail="since is outside the change journal; fetch the full /addresses list") raise HTTPException(status_code=410, detail="since is outside the change journal; fetch the full /addresses list")
@@ -216,13 +230,19 @@ def health():
with db.session() as conn: with db.session() as conn:
counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")} counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")}
pending = db.recreated_pending(conn, ("asn", "fqdn"))
try: try:
last_restore = load_json(cc.RESTORE_FILE, None) # след автовосстановления базы из копии last_restore = load_json(cc.RESTORE_FILE, None) # след автовосстановления базы из копии
except StorageError: except StorageError:
last_restore = None last_restore = None
healthy = alive and not any(j.get("last_error") for j in jobs.values()) try:
recreated = load_json(cc.RECREATED_FILE, None) # база пересоздана после порчи без копий
except StorageError:
recreated = None
healthy = alive and not pending and not any(j.get("last_error") for j in jobs.values())
return { return {
"status": "ok" if healthy else "degraded", "status": "ok" if healthy else "degraded",
"collector_alive": alive, "collector_alive": alive,
@@ -230,6 +250,8 @@ def health():
"jobs": jobs, "jobs": jobs,
"counts": counts, "counts": counts,
"last_restore": last_restore, "last_restore": last_restore,
# Только время и признак ожидания (пути карантина наружу не отдаём)
"db_recreated": {"at": recreated.get("at"), "pending": pending} if recreated else None,
} }
+5
View File
@@ -23,6 +23,7 @@ COLLECT_REQUEST_FILE = os.path.join(DATA_DIR, "collect_request.json") # API ->
# Резервные копии базы (задание backup в демоне); по умолчанию на том же томе, каталог можно вынести # Резервные копии базы (задание backup в демоне); по умолчанию на том же томе, каталог можно вынести
BACKUP_DIR = os.environ.get("RIPE_BACKUP_DIR", os.path.join(DATA_DIR, "backups")) BACKUP_DIR = os.environ.get("RIPE_BACKUP_DIR", os.path.join(DATA_DIR, "backups"))
RESTORE_FILE = os.path.join(DATA_DIR, "last_restore.json") # след автовосстановления базы (читает /health) RESTORE_FILE = os.path.join(DATA_DIR, "last_restore.json") # след автовосстановления базы (читает /health)
RECREATED_FILE = os.path.join(DATA_DIR, "db_recreated.json") # база пересоздана после порчи без копий (см. db.recreated_pending)
BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json" BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json"
DEFAULT_TTL_DAYS = 90 DEFAULT_TTL_DAYS = 90
@@ -163,6 +164,8 @@ class CIDRCollector:
logger.info("Expired %d prefixes of unconfigured ASNs", swept) logger.info("Expired %d prefixes of unconfigured ASNs", swept)
db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS)) db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS))
logger.info("CIDR data saved to %s", DB_FILE) logger.info("CIDR data saved to %s", DB_FILE)
with db.session() as conn:
db.settle_recreated(conn)
class FQDNCollector: class FQDNCollector:
@@ -225,6 +228,8 @@ class FQDNCollector:
logger.info("Expired %d IPs of unconfigured FQDNs", swept) logger.info("Expired %d IPs of unconfigured FQDNs", swept)
db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS)) db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS))
logger.info("FQDN data saved to %s", DB_FILE) logger.info("FQDN data saved to %s", DB_FILE)
with db.session() as conn:
db.settle_recreated(conn)
def main(): def main():
+54 -9
View File
@@ -84,7 +84,9 @@ def _recover(path, error, cc):
"""Порча базы при открытии: карантин и восстановление из последней исправной копии. """Порча базы при открытии: карантин и восстановление из последней исправной копии.
API и демон могут обнаружить порчу одновременно, поэтому всё выполняется под блокировкой, API и демон могут обнаружить порчу одновременно, поэтому всё выполняется под блокировкой,
а внутри неё база проверяется повторно (другой процесс мог уже восстановить). Нет копий - StorageError. а внутри неё база проверяется повторно (другой процесс мог уже восстановить или пересоздать).
Нет исправных копий - создаётся новая пустая база и ставится метка db_recreated.json: пока данные
не собраны заново, API не отдаёт пустой список за настоящий (см. recreated_pending).
""" """
with file_lock(path + ".restore"): with file_lock(path + ".restore"):
try: try:
@@ -109,16 +111,22 @@ def _recover(path, error, cc):
save_json_atomic(cc.RESTORE_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"), save_json_atomic(cc.RESTORE_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"),
"backup": candidate, "quarantine": quarantine, "error": str(error)}) "backup": candidate, "quarantine": quarantine, "error": str(error)})
return conn return conn
raise StorageError(f"{path} is corrupted and no valid backup was found") from error logger.error("No valid backup for %s: a new empty database is created; the API withholds "
"addresses until the collector gathers data again (remove %s to override)", path, cc.RECREATED_FILE)
# Метка ставится до создания базы: сбой между шагами не оставит пустую базу без метки
def _restore_backup(candidate, path, cc): save_json_atomic(cc.RECREATED_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"),
"""Кладёт копию на место базы и сбрасывает журнал изменений (его номера после точки копии уже выдавались).""" "quarantine": quarantine, "error": str(error)})
tmp = path + ".restore.tmp"
shutil.copyfile(candidate, tmp)
os.replace(tmp, path)
conn = _open(path, cc) conn = _open(path, cc)
try: try:
_reset_journal(conn)
except BaseException:
conn.close()
raise
return conn
def _reset_journal(conn):
"""Очищает журнал и сдвигает счётчик: курсоры, выданные до потери или отката базы, станут недействительными."""
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
row = conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'changes'").fetchone() row = conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'changes'").fetchone()
@@ -135,12 +143,49 @@ def _restore_backup(candidate, path, cc):
conn.execute("ROLLBACK") conn.execute("ROLLBACK")
raise raise
conn.execute("COMMIT") conn.execute("COMMIT")
def _restore_backup(candidate, path, cc):
"""Кладёт копию на место базы и сбрасывает журнал изменений (его номера после точки копии уже выдавались)."""
tmp = path + ".restore.tmp"
shutil.copyfile(candidate, tmp)
os.replace(tmp, path)
conn = _open(path, cc)
try:
_reset_journal(conn)
except BaseException: except BaseException:
conn.close() conn.close()
raise raise
return conn return conn
def recreated_pending(conn, kinds):
"""True, если базу пересоздали после порчи без копий и по запрошенным типам данные ещё не собраны заново.
Тип считается неготовым, когда для него в config.json есть источники, а в базе нет ни одного значения;
тип без источников готов (пустой список законен). Нечитаемый конфиг - блокировка сохраняется.
"""
import cidr_collector as cc
if not os.path.exists(cc.RECREATED_FILE):
return False
try:
config = cc.load_full_config()
except StorageError:
return True
configured = {"asn": config.get("asns"), "fqdn": config.get("fqdns")}
return any(configured[kind] and count_values(conn, kind) == 0 for kind in kinds)
def settle_recreated(conn):
"""Снимает метку пересоздания, когда данные по всем типам собраны заново. Вызывается после сбора."""
import cidr_collector as cc
if os.path.exists(cc.RECREATED_FILE) and not recreated_pending(conn, ("asn", "fqdn")):
os.remove(cc.RECREATED_FILE)
logger.info("Data gathered again after the database loss; %s removed", cc.RECREATED_FILE)
def backup_database(conn, now, keep, backup_dir=None): def backup_database(conn, now, keep, backup_dir=None):
"""Онлайн-копия базы с проверкой и ротацией. Возвращает путь копии. """Онлайн-копия базы с проверкой и ротацией. Возвращает путь копии.
+35
View File
@@ -0,0 +1,35 @@
# План: защита от пустой выдачи после потери базы (находка 1 ревью)
Источник: `docs/review-2026-09-21.md`, находка 1 (высокая); риск 3 анализа.
## Проблема
Если `ripe.db` испорчена и исправной копии нет, первый запрос получает 503, а затем создаётся новая пустая база, и `/addresses` отвечает `200 []`. Потребитель (роутер, файрвол), забирающий список по расписанию, может принять пустой список за истину и стереть свои правила. `/health` при живом демоне показывает `ok` при `counts = 0`.
## Дизайн
- **Признак потери.** Когда база пересоздана из-за порчи без копий, `db._recover` пишет файл-метку `db_recreated.json` в `DATA_DIR` (время, путь карантина, текст ошибки). Метка ставится только в этом случае: чистая установка (базы ещё не было) и восстановление из копии её не создают.
- **Сразу рабочее соединение.** `_recover` больше не бросает `StorageError` в этом случае, а создаёт новую базу и возвращает соединение (демон продолжает сбор, `/health` и записи работают). Журнал изменений новой базы сдвигается так же, как при восстановлении из копии (`RESTORE_JOURNAL_JUMP`): курсоры старой базы дают `410`. Общий код сброса журнала выносится из `_restore_backup`.
- **Что блокируется.** Пока данные не собраны заново, `GET /addresses` и `GET /addresses/diff` отвечают `503` с заголовком `Retry-After` и пояснением; остальные эндпоинты работают. Проверка идёт по запрошенным типам:
- блокируется тип, для которого в `config.json` есть источники, но в базе по нему нет ни одного значения;
- тип без источников (например, нет ни одного FQDN) не блокируется и отдаёт законный пустой список.
- **Снятие метки.** В конце каждого запуска сбора (`ASN`, `FQDN`, в том числе через CLI) вызывается `db.settle_recreated`: если ни один сконфигурированный тип не пуст, файл-метка удаляется. Снять блокировку вручную (принять пустую выдачу) можно, удалив `db_recreated.json`.
- **`/health`.** Поле `db_recreated`: `null` или `{"at": ..., "pending": true|false}` (без путей: это же закрывает связанную находку 6 для нового поля). Пока блокировка активна, статус `degraded`.
- **Устойчивость.** Проверка метки читает только файл и базу; если `config.json` нечитаем, блокировка сохраняется (fail closed).
## Изменения
1. `cidr_collector.py`: `RECREATED_FILE`, вызов `db.settle_recreated` в обоих `run_collection`.
2. `db.py`: метка и создание новой базы в `_recover`, `_reset_journal` (общий код с `_restore_backup`), `recreated_pending(conn, kinds)`, `settle_recreated(conn)`.
3. `api_server.py`: проверка в `/addresses` и `/addresses/diff` (503 + `Retry-After`), поле `db_recreated` и статус в `/health`.
4. `README.md`: раздел «Automatic restore» (поведение без копий, ручное снятие метки), `/health`.
5. `.gitignore`/`.dockerignore`: `db_recreated.json`.
6. Тесты (2 новых, один существующий обновляется; всего 24): БД (порча без копий: соединение рабочее, метка записана, старый курсор `410`; метка снимается при появлении данных и не мешает, если источников нет); API (`/addresses` и `/addresses/diff` дают 503, `/health` `degraded` с `db_recreated`, после появления данных 200 и метка снята; тип без источников не блокируется). Существующая проверка «без копий -> StorageError» в `test_restore_from_backup` заменяется на новое поведение.
## Не входит
- Чистая установка без базы по-прежнему отдаёт пустой список (терять нечего).
- **Удаление файла базы вручную не распознаётся** как потеря (порчи нет): пустая база создаётся без метки. Возможное продолжение: при отсутствии базы и наличии копий восстанавливаться из них.
- Порча внутри файла, обнаруженная при чтении, по-прежнему даёт 503 и восстанавливается вручную.
## Проверка
Тесты в контейнере; вручную в отдельных процессах: порча `ripe.db` без копий при работающих API и демоне -> `/addresses` 503, `/health` `degraded`, после сбора 200 с данными и метка удалена; повтор в Docker Compose (общий том).
## Откат
Убрать проверку в API и запись метки в `_recover` (возврат к предыдущему коммиту): схема базы не меняется, файл `db_recreated.json` можно удалить.
+2 -2
View File
@@ -12,7 +12,7 @@
| # | Серьёзность | Находка | Где | Статус | | # | Серьёзность | Находка | Где | Статус |
|---|---|---|---|---| |---|---|---|---|---|
| 1 | **Высокая** | **Пустой список после порчи базы без копий (подтверждено запуском).** Первый запрос даёт 503, затем создаётся новая пустая база, и `/addresses` отвечает `200 []`. Если демон жив, `/health` показывает `ok` при `counts = 0`. Потребители, забирающие список по расписанию, могут стереть свои списки. | `db.py:83-112` | Ждёт решения | | 1 | **Высокая** | **Пустой список после порчи базы без копий (подтверждено запуском).** Первый запрос даёт 503, затем создаётся новая пустая база, и `/addresses` отвечает `200 []`. Если демон жив, `/health` показывает `ok` при `counts = 0`. Потребители, забирающие список по расписанию, могут стереть свои списки. | `db.py:83-112` | Исправлено (`summary-loss-guard.md`) |
| 2 | Средняя | **500 вместо 401 при нелатинском `X-API-Key` (подтверждено).** `secrets.compare_digest` для `str` работает только с ASCII. Обхода авторизации нет. | `api_server.py:105` | Исправлено (`summary-input-hardening.md`) | | 2 | Средняя | **500 вместо 401 при нелатинском `X-API-Key` (подтверждено).** `secrets.compare_digest` для `str` работает только с ASCII. Обхода авторизации нет. | `api_server.py:105` | Исправлено (`summary-input-hardening.md`) |
| 3 | Средняя | **500 при некорректном `since` (подтверждено):** `since=²` (`str.isdigit()` истинно для символов юникода, а `int()` их не принимает), число длиннее 4300 цифр (лимит `int`), крайние даты с поясом (`0001-01-01T00:00:00+05:00`, `9999-12-31T23:59:59-05:00`: `OverflowError`). | `api_server.py:152` | Исправлено (`summary-input-hardening.md`) | | 3 | Средняя | **500 при некорректном `since` (подтверждено):** `since=²` (`str.isdigit()` истинно для символов юникода, а `int()` их не принимает), число длиннее 4300 цифр (лимит `int`), крайние даты с поясом (`0001-01-01T00:00:00+05:00`, `9999-12-31T23:59:59-05:00`: `OverflowError`). | `api_server.py:152` | Исправлено (`summary-input-hardening.md`) |
| 4 | Средняя | **DNS-адреса без фильтрации (подтверждено).** В списки попадают любые ответы: `127.0.0.1`, `10.x`, `0.0.0.0`. Оставлять нужно только глобальные адреса. | `cidr_collector.py:176-179` | Исправлено (`summary-input-hardening.md`) | | 4 | Средняя | **DNS-адреса без фильтрации (подтверждено).** В списки попадают любые ответы: `127.0.0.1`, `10.x`, `0.0.0.0`. Оставлять нужно только глобальные адреса. | `cidr_collector.py:176-179` | Исправлено (`summary-input-hardening.md`) |
@@ -44,5 +44,5 @@
## Рекомендуемый порядок ## Рекомендуемый порядок
1. Доработка «Безопасность ввода и данных»: п. 2, 3, 4 и тесты (`plan-input-hardening.md`). 1. Доработка «Безопасность ввода и данных»: п. 2, 3, 4 и тесты (`plan-input-hardening.md`).
2. Отдельное решение по п. 1: после порчи без копий отвечать 503 до первого успешного сбора или запретить пустую выдачу при только что созданной базе. 2. Находка 1: после порчи без копий отвечать 503 до первого успешного сбора (`plan-loss-guard.md`, выполнено).
3. Пункты 5-10 объединить с доработкой наблюдаемости (п. 2 плана из анализа). 3. Пункты 5-10 объединить с доработкой наблюдаемости (п. 2 плана из анализа).
+21
View File
@@ -0,0 +1,21 @@
# Итоги: защита от пустой выдачи после потери базы (находка 1 ревью)
План: `docs/plan-loss-guard.md`. Источник: `docs/review-2026-09-21.md`.
## Сделано
- **Метка потери** (`db._recover`): при порче базы без исправных копий пишется `db_recreated.json` (до создания базы) и создаётся новая пустая база; соединение сразу рабочее (раньше первый запрос получал 503, следующие - `200 []`). Журнал изменений новой базы сдвигается общим `_reset_journal` (вынесен из `_restore_backup`): курсоры старой базы дают `410`.
- **Блокировка выдачи:** `GET /addresses` и `GET /addresses/diff` отвечают `503` с `Retry-After: 300`, пока по запрошенным типам данные не собраны заново. Тип блокируется, если для него в `config.json` есть источники, а в базе нет значений; тип без источников не блокируется. Нечитаемый конфиг - блокировка сохраняется.
- **Снятие метки:** `db.settle_recreated` в конце каждого запуска сбора (ASN, FQDN, CLI); ручное снятие - удалить `db_recreated.json`.
- **`/health`:** поле `db_recreated` (`at`, `pending`, без путей); при `pending` статус `degraded`.
- **Прочее:** `db_recreated.json` в `.gitignore`/`.dockerignore`, README (раздел о восстановлении, `/health`), находка 1 в отчёте ревью отмечена исправленной.
- **Тесты:** 2 новых (метка, курсор, снятие; API: 503 на обоих эндпоинтах, тип без источников, `/health`, возобновление) и 1 обновлён. Всего 24, в контейнере 24 passed. Добавлен `tests/conftest.py`: автоматическая изоляция файлов состояния всех тестов во временный каталог (без неё один из тестов оставлял метку в каталоге проекта, и следующий тест получал 503).
## Проверка
- Отдельные процессы (демон и API, реальный DNS): после сбора `example.com` выдача есть; порча `ripe.db` без копий -> `/addresses` и `/addresses/diff` 503, `Retry-After: 300`, `/health` `degraded` и `db_recreated: {pending: true}`, запрос `type=cidr` (ASN не настроены) -> `[]` 200; повторный `POST /collect` -> 200 с данными, `/health` `ok`, `db_recreated.json` удалён.
- Docker Compose (отдельный проект, порт 18000, стенд убран): тот же сценарий в томе, оба сервиса `healthy`.
- В первом ручном прогоне сбор не запустился из-за ошибки в моём скрипте (заголовки не разделились по словам, 401); прогон повторён.
## Замечания
- Пока RIPE или DNS недоступны после потери, выдача остаётся 503: так задумано (лучше 503, чем пустой список); обход - удалить `db_recreated.json`.
- **Не распознаётся как потеря:** удаление файла базы вручную (порчи нет, пустая база создаётся без метки) и чистая установка. Возможное продолжение: при отсутствии базы и наличии копий восстанавливаться из них.
- Тип с источниками, для которого сбор вообще ничего не находит (например, все адреса FQDN отфильтрованы как неглобальные), останется в ожидании; при необходимости - удалить метку вручную.
+18
View File
@@ -0,0 +1,18 @@
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import cidr_collector as cc
@pytest.fixture(autouse=True)
def isolated_state(tmp_path, monkeypatch):
"""Файлы состояния всех тестов - во временном каталоге: ни один тест не трогает каталог проекта и не видит чужие метки."""
for attr, name in (("CONFIG_FILE", "config.json"), ("DB_FILE", "ripe.db"), ("DATA_FILE", "data.json"),
("FQDN_DATA_FILE", "fqdn_data.json"), ("STATUS_FILE", "status.json"),
("COLLECT_REQUEST_FILE", "collect_request.json"), ("RESTORE_FILE", "last_restore.json"),
("RECREATED_FILE", "db_recreated.json"), ("BACKUP_DIR", "backups")):
monkeypatch.setattr(cc, attr, str(tmp_path / name))
+4 -4
View File
@@ -56,12 +56,12 @@ def test_corrupted_storage_is_preserved(files):
assert not path.exists() assert not path.exists()
assert len(list(files.glob("data.json.corrupt-*"))) == 1 assert len(list(files.glob("data.json.corrupt-*"))) == 1
# Битая база: убирается в сторону, ошибка отдаётся как StorageError # Битая база без копий: оригинал убран в сторону, создана новая пустая база и поставлена метка пересоздания
(files / "ripe.db").write_bytes(b"this is not a sqlite database" * 100) (files / "ripe.db").write_bytes(b"this is not a sqlite database" * 100)
with pytest.raises(StorageError): with db.session() as conn:
db.connect() assert db.get_values(conn) == []
assert not (files / "ripe.db").exists()
assert len(list(files.glob("ripe.db.corrupt-*"))) == 1 assert len(list(files.glob("ripe.db.corrupt-*"))) == 1
assert (files / "db_recreated.json").exists()
def test_post_schedule_auth(files, monkeypatch): def test_post_schedule_auth(files, monkeypatch):
+23 -3
View File
@@ -19,6 +19,8 @@ def files(tmp_path, monkeypatch):
monkeypatch.setattr(cc, attr, str(tmp_path / name)) monkeypatch.setattr(cc, attr, str(tmp_path / name))
monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups")) monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups"))
monkeypatch.setattr(cc, "RESTORE_FILE", str(tmp_path / "last_restore.json")) monkeypatch.setattr(cc, "RESTORE_FILE", str(tmp_path / "last_restore.json"))
monkeypatch.setattr(cc, "RECREATED_FILE", str(tmp_path / "db_recreated.json"))
monkeypatch.setattr(cc, "CONFIG_FILE", str(tmp_path / "config.json"))
return tmp_path return tmp_path
@@ -121,9 +123,27 @@ def test_restore_from_backup(files):
assert len(glob.glob(str(files / "ripe.db.corrupt-*"))) == 1 assert len(glob.glob(str(files / "ripe.db.corrupt-*"))) == 1
assert json.loads((files / "last_restore.json").read_text())["backup"].endswith(".db") assert json.loads((files / "last_restore.json").read_text())["backup"].endswith(".db")
# Без исправных копий - прежнее поведение: ошибка хранилища, база в карантине # Без исправных копий создаётся новая пустая база (с меткой, см. test_recreated_database_guard)
for backup in db.list_backups(cc.BACKUP_DIR): for backup in db.list_backups(cc.BACKUP_DIR):
os.unlink(backup) os.unlink(backup)
(files / "ripe.db").write_bytes(b"garbage" * 1000) (files / "ripe.db").write_bytes(b"garbage" * 1000)
with pytest.raises(db.StorageError): with db.session() as conn:
db.connect() assert db.get_values(conn) == []
assert os.path.exists(cc.RECREATED_FILE) and glob.glob(str(files / "ripe.db.corrupt-*"))
def test_recreated_database_guard(files):
(files / "config.json").write_text('{"asns": [1], "fqdns": []}')
(files / "ripe.db").write_bytes(b"garbage" * 1000) # порча, копий нет
with db.session() as conn:
assert os.path.exists(cc.RECREATED_FILE)
# ASN настроен, данных нет - ожидание; FQDN не настроен - пустой список законен
assert db.recreated_pending(conn, ("asn",)) and not db.recreated_pending(conn, ("fqdn",))
assert db.get_changes(conn, {"asn"}, cursor=5) is None # курсор старой базы недействителен
# Данные собраны заново - сборщик снимает метку
with db.transaction(conn):
db.merge_source(conn, "asn", "1", {"a"}, datetime.datetime.now(), 90)
db.settle_recreated(conn)
assert not os.path.exists(cc.RECREATED_FILE)
+26
View File
@@ -21,6 +21,9 @@ def env(tmp_path, monkeypatch):
monkeypatch.setattr(cc, "DATA_FILE", str(tmp_path / "data.json")) monkeypatch.setattr(cc, "DATA_FILE", str(tmp_path / "data.json"))
monkeypatch.setattr(cc, "FQDN_DATA_FILE", str(tmp_path / "fqdn_data.json")) monkeypatch.setattr(cc, "FQDN_DATA_FILE", str(tmp_path / "fqdn_data.json"))
monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db")) monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db"))
monkeypatch.setattr(cc, "RECREATED_FILE", str(tmp_path / "db_recreated.json"))
monkeypatch.setattr(cc, "RESTORE_FILE", str(tmp_path / "last_restore.json"))
monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups"))
monkeypatch.setenv("RIPE_API_TOKEN", "secret") monkeypatch.setenv("RIPE_API_TOKEN", "secret")
save_json_atomic(cc.CONFIG_FILE, {"asns": [], "fqdns": [], "ttl_days": 90}) save_json_atomic(cc.CONFIG_FILE, {"asns": [], "fqdns": [], "ttl_days": 90})
return TestClient(api_server.app) return TestClient(api_server.app)
@@ -106,3 +109,26 @@ def test_input_hardening(env):
assert env.get("/addresses/diff", params={"since": bad}).status_code == 400, bad assert env.get("/addresses/diff", params={"since": bad}).status_code == 400, bad
# Корректный, но выходящий за журнал курсор - 410, а не 400 # Корректный, но выходящий за журнал курсор - 410, а не 400
assert env.get("/addresses/diff", params={"since": "9" * 30}).status_code == 410 assert env.get("/addresses/diff", params={"since": "9" * 30}).status_code == 410
def test_recreated_database_is_withheld(env):
cc.add_to_config_list("asns", 62041)
with open(cc.DB_FILE, "wb") as f: # порча базы, копий нет
f.write(b"garbage" * 1000)
# Пока данные не собраны заново, пустой список не отдаётся (503 + Retry-After)
for url in ("/addresses", "/addresses/diff?since=0"):
response = env.get(url)
assert response.status_code == 503 and response.headers["Retry-After"], url
assert env.get("/addresses", params={"type": "fqdn"}).json() == [] # тип без источников не блокируется
health = env.get("/health").json()
assert health["status"] == "degraded" and health["db_recreated"]["pending"] is True
assert "quarantine" not in health["db_recreated"] # пути наружу не отдаются
# Сборщик собрал данные: выдача возобновляется, метка снята
with db.session() as conn:
with db.transaction(conn):
db.merge_source(conn, "asn", "62041", {"1.0.0.0/24"}, datetime.datetime.now(), 90)
db.settle_recreated(conn)
assert env.get("/addresses").json() == ["1.0.0.0/24"]
assert env.get("/health").json()["db_recreated"] is None