Files
admin/.wiki/concepts/es-destructive-delete-incident-2026-05-26.md
vitya 48a5cf397e tasks(restore-es-indices-books-vds): closed — snapshot restore + 2 preventive controls
snapshot restore из kreknin:daily-2026-05-25 за 46 сек (epz=820604,
products=105922, artmone=2621, counts == source).

RCA: 5 индексов (включая system .tasks) удалены через ES API DELETE _all
за 1 сек на 2026-05-26 10:21 UTC — 1ч 11мин после создания bookva-es в
cutover-prep. Каноничный endpoint elasticsearch.kzntsv.site попал под
команду которая предназначалась bookva-es:9200 (internal-only, без
traefik route). Caller identity unrecoverable: ES audit = X-Pack платный,
traefik accessLog был выключен, Portainer CE без audit.

Preventive controls applied + verified:
1. ES env action.destructive_requires_name=true (stack 33) — DELETE _all
   и wildcard теперь 400 BadRequest; by-name DELETE работает (нужно для
   reindex). Pattern удаления что случился физически невозможен.
2. Traefik JSON accessLog в /letsencrypt/access.log — будущие DELETE
   оставят forensic след с IP/user/method/path.

Wiki concept: .wiki/concepts/es-destructive-delete-incident-2026-05-26.md
с recovery runbook + preventive controls + cross-refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:11:07 +03:00

125 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: ES destructive delete incident — canonical endpoint vs internal-only bookva-es
type: concept
tags: [elasticsearch, incident, books-vds, tenant-cutover, operator-error]
related: [[../entities/books-vds]], [[../sources/books-vds-backup-daily-kreknin-2026-05-25]]
updated: 2026-05-28
---
# ES destructive delete incident — canonical endpoint vs internal-only bookva-es
## Summary
В период `bookva-tenant-cutover-prep` (2026-05-26) **дважды** были удалены все user-индексы (`epz`, `products`, `artmone`, плюс system `.tasks` и placeholder `read_me`) на canonical ES `elasticsearch.kzntsv.site` (books VDS stack 33). Удаление выполнено через ES REST API одной командой типа `DELETE _all` / `DELETE *` (5 индексов за <1 секунды). Контейнер не рестартовал, bind-volume не задет — данные стёрты на уровне cluster state.
**Caller identity не восстановим** — ES audit log = X-Pack платная фича (отсутствует на free 7.10), traefik accessLog был выключен, Portainer audit log = enterprise.
## Hypothesis (timeline-based, не доказан)
Оператор в момент cutover-prep хотел очистить **новый bookva-es** перед заполнением, но команда ушла на canonical `elasticsearch.kzntsv.site` вместо `bookva-es:9200`.
```
2026-05-25 09:51 UTC daily snapshot SUCCESS [read_me, products, epz, artmone, .tasks] ← данные ЕСТЬ
...
2026-05-26 03:02 UTC daily snapshot success [read_me] only ← Wipe #1 случился в окне
2026-05-26 05:4906:44 UTC re-reindex artmone+epz+products восстанавливают данные
2026-05-26 09:10:18 UTC bookva-es container created (cutover-prep Step 2)
2026-05-26 10:21:4344 UTC Wipe #2: 5 индексов удалены за 1 сек ← 1ч 11мин после bookva-es
```
`bookva-es` использует named volume `bookva-es-data`, БЕЗ traefik labels (internal-only через docker network `proxy`). Не задел canonical через volume mount или routing. `DELETE` с воркстейшна на `bookva-es:9200` физически невозможен (порт не expose'нут наружу); на `elasticsearch.kzntsv.site` — возможен через traefik basicAuth.
## Forensics — что есть и чего нет
| Источник | Доступность | Содержание |
|---|---|---|
| ES container log `docker logs elasticsearch` | ✅ есть | `o.e.c.m.MetadataDeleteIndexService` пишет факт + index UUID + timestamp. Caller отсутствует |
| ES audit log | ❌ нет | X-Pack Security платная фича. Free 7.10 audit отключён |
| Traefik accessLog | ❌ был выключен | Файл `traefik.yml` не содержал секции `accessLog:`. Запросы (включая DELETE) не пишутся |
| Portainer audit | ❌ нет | Portainer CE без enterprise = audit log не включён |
| Snapshot diff `_snapshot/kreknin/_all` | ✅ есть | Daily snapshots показывают какой день имеет какие индексы → окно wipe'а |
| Shell history `~/.bash_history` root | ⚠️ ограниченно | Только если delete был сделан с самого VDS. Если с воркстейшна / Kibana DevTools — пусто |
## Recovery procedure
Если индексы исчезли — **snapshot restore** из `kreknin` repo. Daily snapshot pipeline (`books-vds-backup-daily-kreknin`, cron 06:00 MSK) автоматически снапшотит непустые индексы. До wipe'а snapshot контейнерит full data → restore = ~46 сек для ~800MB (I/O bound, файлы уже на диске).
```bash
ssh -i ~/.ssh/id_ed25519_books_ops root@89.253.255.133 'bash -s' <<'EOF'
PW=$(python -c "import json; print(json.load(open('/opt/books/api/config/default.json'))['elasticsearch']['auth']['password'])")
# 1. Найти последний snapshot С полными данными
curl -sk -u "books:$PW" 'https://elasticsearch.kzntsv.site/_snapshot/kreknin/_all' \
| python -c "import json,sys; [print(s['snapshot'], s['indices']) for s in json.load(sys.stdin)['snapshots']]"
# 2. Restore (synchronous, не overwrites open indices — current state должен быть пустой)
curl -sk -u "books:$PW" -H 'Content-Type: application/json' -X POST \
'https://elasticsearch.kzntsv.site/_snapshot/kreknin/daily-<YYYY-MM-DD>/_restore?wait_for_completion=true' \
-d '{"indices":"epz,products,artmone","include_global_state":false,"include_aliases":true,"index_settings":{"number_of_replicas":0}}'
# 3. Verify counts == source
curl -sk -u "books:$PW" 'https://elasticsearch.kzntsv.site/_cat/indices?v'
# 4. Restart consumers (reset connection pool)
docker restart books-api books-task-runner books-job-scheduler
EOF
```
Если все snapshot'ы посткоммитные (содержат только placeholder), fallback на reindex-from-remote из `elasticold.kzntsv.site` (см. [[../../tasks/migrate-elasticsearch-to-books-vds]] § Playbook). Source ES не выключен per migration policy.
## Preventive controls (applied 2026-05-28)
### Control #1 — ES `action.destructive_requires_name=true`
Env var на stack 33 (Portainer-managed). После применения wildcard/_all delete возвращают **400 Bad Request**:
```
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/_all'
{"error":{"type":"illegal_argument_exception","reason":"Wildcard expressions or all indices are not allowed"},"status":400}
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/ep*'
{"error":{"type":"illegal_argument_exception","reason":"Wildcard expressions or all indices are not allowed"},"status":400}
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/epz'
{"acknowledged":true} # legit by-name delete still works
```
Класс ошибки «DELETE wildcard на неправильный endpoint» теперь блокируется ES до того, как достанется до данных. Reindex по одному индексу остаётся легитимным (нужно для миграции). Полностью **физически невозможно** удалить >1 индекса одной командой.
Apply: добавить `action.destructive_requires_name: "true"` в `environment:` блок stack 33 compose, PUT через Portainer API endpoint `/api/stacks/33?endpointId=1`. ES container recreate ~10 сек, consumers переподключатся через retry.
### Control #2 — Traefik accessLog (JSON)
Без этого никакая ретроспективная диагностика невозможна. Static config в `/usr/docker/traefik/data/traefik.yml`:
```yaml
accessLog:
filePath: /letsencrypt/access.log
format: json
bufferingSize: 100
```
`/letsencrypt/` уже bind-mounted (`./letsencrypt:/letsencrypt` в `/usr/docker/traefik/docker-compose.yml`), файл persistent. Restart container — изменения подхватились (accessLog = static config, не dynamic).
Sample entry:
```json
{"ClientHost":"...", "ClientUsername":"books", "RequestMethod":"DELETE", "RequestHost":"elasticsearch.kzntsv.site",
"RequestPath":"/_all", "RouterName":"elasticsearch@docker", "DownstreamStatus":400, ...}
```
Будущие подозрительные запросы видны с IP, username basicAuth, methodом, путём, response status. Логирование = ~3050 MB/day при типичной нагрузке (~1 req/sec).
## Follow-ups
- **Logrotate** на `/usr/docker/traefik/letsencrypt/access.log` — пока без него файл будет расти; не критично (disk free 63G, ressedimption ~12 года), но micro-task на logrotate cron имеет смысл.
- **bookva-es traefik route** — если когда-нибудь bookva-es потребует внешнего доступа, обязательно отдельный hostname (`bookva-es.kzntsv.site`) с отдельной basicAuth. **Никогда** не делиться `elasticsearch.kzntsv.site` между slovo/bookva.
- **Snapshot retention review** — сейчас 7 daily snapshots. Если бы инцидент проявился через 8 дней, daily-25.05 был бы вытеснен. Для критичных корпусов имеет смысл добавить weekly/monthly tier. Отдельная мини-задача.
- **Логирование callers без accessLog ретроспективно невозможно** — это main learning. Любой новый production endpoint должен иметь access log с момента создания.
## Cross-refs
- [[../entities/books-vds]] § Стек, ES — stack 33 inventory.
- [[../sources/books-vds-backup-daily-kreknin-2026-05-25]] — backup pipeline который сделал spasibo daily-25.05 snapshot.
- [[../../tasks/restore-elasticsearch-indices-books-vds]] — incident response trace (этой сессии).
- [[../../tasks/migrate-elasticsearch-to-books-vds]] — original migration playbook (применим для reindex-from-remote fallback).