diff --git a/.dockerignore b/.dockerignore index c6a1143..e8fea9b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,3 +17,4 @@ collect_request.json graphify-out/ backups/ last_restore.json +db_recreated.json diff --git a/.gitignore b/.gitignore index 56a627d..530fb9e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ data.json fqdn_data.json backups/ last_restore.json +db_recreated.json diff --git a/README.md b/README.md index 2b51b18..5da6bef 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ Body is optional: `type` is `asn`, `fqdn` or `all` (default). The API does not c ### Endpoint: 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-.db` and stored in `RIPE_BACKUP_DIR` (default `/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-` 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-` 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). - 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. diff --git a/api_server.py b/api_server.py index eac2f0a..c6d88ba 100644 --- a/api_server.py +++ b/api_server.py @@ -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.") +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]: with db.session() as conn: return db.get_values(conn, "asn") @@ -133,6 +146,7 @@ def get_addresses( ): # Курсор читаем до данных: изменения между чтением курсора и данных повторятся в diff, что безвредно with db.session() as conn: + ensure_data_ready(conn, kinds_of(type)) headers = {"X-Changes-Cursor": str(db.journal_head(conn))} results = set() @@ -175,9 +189,9 @@ def get_addresses_diff( ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"), ): cursor, since_ts = parse_since(since) - kinds = {"asn" if t == AddressType.cidr else "fqdn" - for t in (AddressType.cidr, AddressType.fqdn) if type in (t, AddressType.all_types)} + kinds = kinds_of(type) with db.session() as conn: + ensure_data_ready(conn, kinds) changes = db.get_changes(conn, kinds, cursor, since_ts) if changes is None: 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: counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")} + pending = db.recreated_pending(conn, ("asn", "fqdn")) try: last_restore = load_json(cc.RESTORE_FILE, None) # след автовосстановления базы из копии except StorageError: 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 { "status": "ok" if healthy else "degraded", "collector_alive": alive, @@ -230,6 +250,8 @@ def health(): "jobs": jobs, "counts": counts, "last_restore": last_restore, + # Только время и признак ожидания (пути карантина наружу не отдаём) + "db_recreated": {"at": recreated.get("at"), "pending": pending} if recreated else None, } diff --git a/cidr_collector.py b/cidr_collector.py index 9b35a87..e7f853f 100644 --- a/cidr_collector.py +++ b/cidr_collector.py @@ -23,6 +23,7 @@ COLLECT_REQUEST_FILE = os.path.join(DATA_DIR, "collect_request.json") # API -> # Резервные копии базы (задание backup в демоне); по умолчанию на том же томе, каталог можно вынести 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) +RECREATED_FILE = os.path.join(DATA_DIR, "db_recreated.json") # база пересоздана после порчи без копий (см. db.recreated_pending) BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json" DEFAULT_TTL_DAYS = 90 @@ -163,6 +164,8 @@ class CIDRCollector: logger.info("Expired %d prefixes of unconfigured ASNs", swept) db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS)) logger.info("CIDR data saved to %s", DB_FILE) + with db.session() as conn: + db.settle_recreated(conn) class FQDNCollector: @@ -225,6 +228,8 @@ class FQDNCollector: logger.info("Expired %d IPs of unconfigured FQDNs", swept) db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS)) logger.info("FQDN data saved to %s", DB_FILE) + with db.session() as conn: + db.settle_recreated(conn) def main(): diff --git a/db.py b/db.py index 2683e9b..56c71ac 100644 --- a/db.py +++ b/db.py @@ -84,7 +84,9 @@ def _recover(path, error, cc): """Порча базы при открытии: карантин и восстановление из последней исправной копии. API и демон могут обнаружить порчу одновременно, поэтому всё выполняется под блокировкой, - а внутри неё база проверяется повторно (другой процесс мог уже восстановить). Нет копий - StorageError. + а внутри неё база проверяется повторно (другой процесс мог уже восстановить или пересоздать). + Нет исправных копий - создаётся новая пустая база и ставится метка db_recreated.json: пока данные + не собраны заново, API не отдаёт пустой список за настоящий (см. recreated_pending). """ with file_lock(path + ".restore"): try: @@ -109,7 +111,38 @@ def _recover(path, error, cc): save_json_atomic(cc.RESTORE_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"), "backup": candidate, "quarantine": quarantine, "error": str(error)}) 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) + # Метка ставится до создания базы: сбой между шагами не оставит пустую базу без метки + save_json_atomic(cc.RECREATED_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"), + "quarantine": quarantine, "error": str(error)}) + conn = _open(path, cc) + try: + _reset_journal(conn) + except BaseException: + conn.close() + raise + return conn + + +def _reset_journal(conn): + """Очищает журнал и сдвигает счётчик: курсоры, выданные до потери или отката базы, станут недействительными.""" + conn.execute("BEGIN IMMEDIATE") + try: + row = conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'changes'").fetchone() + horizon = int(conn.execute("SELECT value FROM meta WHERE key = 'horizon_id'").fetchone()[0]) + new_seq = max(row[0] if row else 0, horizon) + RESTORE_JOURNAL_JUMP + conn.execute("DELETE FROM changes") + if row: + conn.execute("UPDATE sqlite_sequence SET seq = ? WHERE name = 'changes'", (new_seq,)) + else: + conn.execute("INSERT INTO sqlite_sequence (name, seq) VALUES ('changes', ?)", (new_seq,)) + conn.execute("UPDATE meta SET value = ? WHERE key = 'horizon_id'", (str(new_seq),)) + conn.execute(f"UPDATE meta SET value = {_TS} WHERE key = 'horizon_ts'") + except BaseException: + conn.execute("ROLLBACK") + raise + conn.execute("COMMIT") def _restore_backup(candidate, path, cc): @@ -119,28 +152,40 @@ def _restore_backup(candidate, path, cc): os.replace(tmp, path) conn = _open(path, cc) try: - conn.execute("BEGIN IMMEDIATE") - try: - row = conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'changes'").fetchone() - horizon = int(conn.execute("SELECT value FROM meta WHERE key = 'horizon_id'").fetchone()[0]) - new_seq = max(row[0] if row else 0, horizon) + RESTORE_JOURNAL_JUMP - conn.execute("DELETE FROM changes") - if row: - conn.execute("UPDATE sqlite_sequence SET seq = ? WHERE name = 'changes'", (new_seq,)) - else: - conn.execute("INSERT INTO sqlite_sequence (name, seq) VALUES ('changes', ?)", (new_seq,)) - conn.execute("UPDATE meta SET value = ? WHERE key = 'horizon_id'", (str(new_seq),)) - conn.execute(f"UPDATE meta SET value = {_TS} WHERE key = 'horizon_ts'") - except BaseException: - conn.execute("ROLLBACK") - raise - conn.execute("COMMIT") + _reset_journal(conn) except BaseException: conn.close() raise 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): """Онлайн-копия базы с проверкой и ротацией. Возвращает путь копии. diff --git a/docs/plan-loss-guard.md b/docs/plan-loss-guard.md new file mode 100644 index 0000000..4932ca5 --- /dev/null +++ b/docs/plan-loss-guard.md @@ -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` можно удалить. diff --git a/docs/review-2026-09-21.md b/docs/review-2026-09-21.md index 0601c26..af5e6e0 100644 --- a/docs/review-2026-09-21.md +++ b/docs/review-2026-09-21.md @@ -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`) | | 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`) | @@ -44,5 +44,5 @@ ## Рекомендуемый порядок 1. Доработка «Безопасность ввода и данных»: п. 2, 3, 4 и тесты (`plan-input-hardening.md`). -2. Отдельное решение по п. 1: после порчи без копий отвечать 503 до первого успешного сбора или запретить пустую выдачу при только что созданной базе. +2. Находка 1: после порчи без копий отвечать 503 до первого успешного сбора (`plan-loss-guard.md`, выполнено). 3. Пункты 5-10 объединить с доработкой наблюдаемости (п. 2 плана из анализа). diff --git a/docs/summary-loss-guard.md b/docs/summary-loss-guard.md new file mode 100644 index 0000000..8494012 --- /dev/null +++ b/docs/summary-loss-guard.md @@ -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 отфильтрованы как неглобальные), останется в ожидании; при необходимости - удалить метку вручную. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4403e12 --- /dev/null +++ b/tests/conftest.py @@ -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)) diff --git a/tests/test_core.py b/tests/test_core.py index 12a2388..4f48dc2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -56,12 +56,12 @@ def test_corrupted_storage_is_preserved(files): assert not path.exists() assert len(list(files.glob("data.json.corrupt-*"))) == 1 - # Битая база: убирается в сторону, ошибка отдаётся как StorageError + # Битая база без копий: оригинал убран в сторону, создана новая пустая база и поставлена метка пересоздания (files / "ripe.db").write_bytes(b"this is not a sqlite database" * 100) - with pytest.raises(StorageError): - db.connect() - assert not (files / "ripe.db").exists() + with db.session() as conn: + assert db.get_values(conn) == [] assert len(list(files.glob("ripe.db.corrupt-*"))) == 1 + assert (files / "db_recreated.json").exists() def test_post_schedule_auth(files, monkeypatch): diff --git a/tests/test_db.py b/tests/test_db.py index 3aaaef3..6fafe5f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -19,6 +19,8 @@ def files(tmp_path, monkeypatch): monkeypatch.setattr(cc, attr, str(tmp_path / name)) monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups")) 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 @@ -121,9 +123,27 @@ def test_restore_from_backup(files): assert len(glob.glob(str(files / "ripe.db.corrupt-*"))) == 1 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): os.unlink(backup) (files / "ripe.db").write_bytes(b"garbage" * 1000) - with pytest.raises(db.StorageError): - db.connect() + with db.session() as conn: + 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) diff --git a/tests/test_sources_api.py b/tests/test_sources_api.py index a714769..4f3a5df 100644 --- a/tests/test_sources_api.py +++ b/tests/test_sources_api.py @@ -21,6 +21,9 @@ def env(tmp_path, monkeypatch): 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, "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") save_json_atomic(cc.CONFIG_FILE, {"asns": [], "fqdns": [], "ttl_days": 90}) 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 # Корректный, но выходящий за журнал курсор - 410, а не 400 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