docs: spec for custom records and orphan domain cleanup

Two operational problems on the domain check page: records deliberately
added outside the template keep showing up as prunes (drift + scheduler
notifications), and domains whose zones were deleted at the provider are
never removed by a re-import.

Design decisions: custom marks are scoped per domain, the template wins
over a mark (a mark only applies to Kind == Delete), marks live in their
own domain_custom_records table, and orphan domains are removed inside
the import transaction with a guard that skips deletion when the provider
returns zero zones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
This commit is contained in:
2026-08-19 17:42:53 +07:00
co-authored by Claude Opus 5
parent ba58921dc7
commit eead9f3f3d
@@ -0,0 +1,238 @@
# Custom-записи в диффе и очистка доменов удалённых зон
Дата: 2026-08-19
Статус: утверждён к реализации
## Задача
Две связанные проблемы эксплуатации, обе всплывают на странице `DOMAIN / CHECK`.
1. **Осознанные записи вне шаблона.** Оператор добавляет в зону запись, которой
нет в шаблоне (`CNAME admin.example.ru.`, `app`, `dav`). Каждый чек кладёт её
в `PRUNES`, домен получает статус `drift`, планировщик шлёт уведомление.
Убрать шум можно только внеся запись в шаблон — но шаблон общий для многих
зон, а запись специфична для одной. Нужна возможность пометить такую запись
«это осознанно, не проверять», с обратимым действием.
2. **Домены исчезнувших зон.** `ImportDomains` только создаёт домены
(`ON CONFLICT DO NOTHING`). Если зона удалена у провайдера, домен остаётся в
БД навсегда: планировщик чекает его, получает ошибку провайдера, домен висит
в статусе `error` и генерирует уведомления. Переимпорт ситуацию не лечит.
## Принятые решения
| Вопрос | Решение |
|---|---|
| Скоуп custom-записей | Привязка к домену. Исключение действует только в своей зоне |
| Конфликт с шаблоном | Шаблон побеждает: пометка действует только на `Kind == Delete` |
| Хранение | Отдельная таблица `domain_custom_records` |
| Orphan-домены | Удаляются в транзакции переимпорта, с гардом на пустой ответ провайдера |
## Часть 1. Custom-записи
### Модель данных
Миграция `0005_domain_custom_records.sql`:
```sql
CREATE TABLE domain_custom_records (
domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
record_key text NOT NULL,
note text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (domain_id, record_key)
);
```
`record_key` — тот же `RecordDiff.Key()` = `"ТИП имя."` (нормализованный,
материализованный: с подставленным именем зоны, без `{{domain_name}}`). Ключ
идентифицирует RRset целиком, а не конкретные значения: если значения записи в
зоне изменятся, она останется скрытой. Это осознанный выбор — «эта запись живёт
вне шаблона, её содержимое сервис не контролирует».
Составной PK делает добавление идемпотентным (`ON CONFLICT DO NOTHING`).
Каскад от `domains` убирает строки вместе с доменом (в том числе при очистке
orphan-доменов из части 2).
`note` заполняется пустой строкой — колонка задел под «зачем добавлено»; UI её
пока не пишет и не показывает.
### Store
Запросы в `internal/store/queries/customs.sql`:
- `AddCustomRecord``INSERT ... ON CONFLICT (domain_id, record_key) DO NOTHING`
- `DeleteCustomRecord``DELETE WHERE domain_id = $1 AND record_key = $2`
- `ListCustomKeys``SELECT record_key ... WHERE domain_id = $1 ORDER BY record_key`
Тенант-скоуп обеспечивается на уровне store-методов: `AddCustom`/`DeleteCustom`
принимают `(domainID, projectID, key)` и сперва делают скоупленный
`GetDomain(domainID, projectID)` — тем же приёмом, что `SetDomainTemplate`
проверяет принадлежность шаблона. Домен чужого проекта → `pgx.ErrNoRows` → 404.
`internal/store/db/*.sql.go` правится вручную (sqlc в среде нет): порядок колонок
в SQL-строке, в `*Row`-структуре и в `row.Scan(...)` обязан совпадать 1:1.
### Дифф
`internal/diff`:
```go
type RecordDiff struct {
// ...
ReadOnly bool // NS/SOA — показываются, но не применяются
Custom bool // осознанная запись вне шаблона — показывается, но не считается дрифтом
}
// MarkCustom помечает диффы, чей ключ есть в keys, как Custom. Помечаются
// ТОЛЬКО Kind == Delete и !ReadOnly: шаблон побеждает — как только шаблон
// начинает описывать этот ключ, запись возвращается в Updates/InSync, а
// лежащая в БД пометка перестаёт действовать (но не удаляется).
func (c *Changeset) MarkCustom(keys []string)
// Customs возвращает помеченные диффы. Не пересекается с Updates()/Prunes().
func (c Changeset) Customs() []RecordDiff
```
`Actionable()`, `Updates()`, `Prunes()` пропускают `Custom` наравне с `ReadOnly`.
Следствия: `service.DeriveStatus` даёт `in_sync`, планировщик по таким доменам
молчит, а `service.Apply` физически не может применить custom-ключ — он
итерирует `cs.Prunes()`, откуда custom исключён (защита от фронта, приславшего
ключ со старого снапшота).
Инвариант, который держим тестом: `Updates()` и `Prunes()` по-прежнему разбивают
`Actionable()` на два непересекающихся множества, а `Customs()` и `ReadOnly`
живут вне `Actionable()`.
### Сервис
`service.DomainRef` получает поле `CustomKeys []string`; `store.LoadDomain`
дочитывает их отдельным запросом `ListCustomKeys`. `resolve()` вызывает
`cs.MarkCustom(ref.CustomKeys)` сразу после `diff.Diff(...)` — единственная точка
пометки, как `tmpl.Materialize` для плейсхолдеров.
### API
Обе ручки — под `RequireAuth` + `RequireProjectAccess`, домен грузится парой
`(did, pid)`:
```
POST /api/projects/{pid}/domains/{did}/customs {"key":"CNAME admin.example.ru."} → 201
DELETE /api/projects/{pid}/domains/{did}/customs?key=CNAME%20admin.example.ru. → 204
```
Пустой `key` → 400. Домен не найден в проекте → 404. Ключ в теле POST и в
query-параметре DELETE, а не в path-сегменте: ключ содержит пробел и точки, и
path-сегмент потребовал бы двойного кодирования.
Ответ чека расширяется полем `customs`, инициализируемым пустым слайсом (nil
даёт JSON `null`, на котором падает `.map` на фронте):
```go
type changesetResponse struct {
Updates []recordView `json:"updates"`
Prunes []recordView `json:"prunes"`
Customs []recordView `json:"customs"`
ReadOnly []recordView `json:"readOnly"`
InSync int `json:"inSyncCount"`
}
```
`recordView` получает поле `custom bool` — по симметрии с `readOnly`, чтобы
строку можно было отрисовать правильно вне зависимости от секции.
### Фронт
`ChangesetResponse` и `RecordView` в `web/src/api/types.ts` — плюс `customs` и
`custom`. Хуки `useAddCustom(domainId)` / `useRemoveCustom(domainId)`
инвалидируют ключ запроса чека.
`DiffView` получает четвёртый тон `custom` (иконка `BookmarkCheck`, свой
CSS-токен `--diff-custom`), секция рендерится между `Prunes` и `Read-only`.
Чекбоксов в ней нет — записи не применяются.
Действия на строках:
- в `Prunes` — кнопка «В customs», `aria-label={`В customs ${type} ${name}`}`
- в `Customs` — кнопка «Вернуть в дифф» (`Undo2`),
`aria-label={`Вернуть в дифф ${type} ${name}`}`
`RecordRow` получает необязательный слот `action?: ReactNode` справа от имени —
секции `update`/`readonly` его не передают.
## Часть 2. Очистка доменов удалённых зон
### Поведение
`ImportDomains` переименовывается по смыслу в синхронизацию: в одной транзакции
1. создаются домены для новых зон (как сейчас, `ON CONFLICT DO NOTHING`);
2. удаляются домены **этого** `provider_account_id`, чьих `zone_id` нет в ответе
провайдера.
Скоуп удаления — аккаунт, а не проект: у проекта может быть несколько
provider-аккаунтов, и зоны одного не должны влиять на домены другого.
### Гард на пустой ответ
Если `ListZones` вернул ноль зон — не удаляется ничего. Пустой список
неотличим от «аккаунт временно потерял доступ к зонам», а ценой ошибки будет
удаление всех доменов аккаунта вместе с историей чеков. Ошибка `ListZones`
поднимается наружу и до store не доходит, так что случай «частичный список без
ошибки» остаётся только теоретическим (в клиенте Selectel — при неувеличивающемся
`next_offset`); гард на пустой ответ закрывает практически значимую часть риска.
### Что удаляется каскадом
`check_runs` (история чеков) и `domain_custom_records` — обе таблицы ссылаются на
`domains` с `ON DELETE CASCADE`. Привязка шаблона у удаляемого домена просто
исчезает; сам шаблон не трогается. Операция необратима — поэтому и нужен гард.
### Контракт ответа
```go
type importResponse struct {
Created []domainResponse `json:"created"`
Removed []domainResponse `json:"removed"`
}
```
Это ломающее изменение: сейчас ручка отдаёт голый массив созданных доменов.
Фронт правится в том же коммите, иначе задеплоенный бандл получит объект там,
где ждёт массив. UI показывает «Создано N, удалено M».
## Тесты
**Go, без Docker:**
- `internal/diff``MarkCustom` метит только `Delete`; шаблон побеждает (ключ,
описанный шаблоном, остаётся в `Updates`); `Actionable/Updates/Prunes`
исключают `Custom`; `Customs()` возвращает помеченные
- `internal/service``resolve` метит диффы ключами из `DomainRef`; `Apply` не
применяет custom-ключ, присланный в `req.Prunes`; `DeriveStatus` = `in_sync`,
когда единственное расхождение — custom
- `internal/api` — POST/DELETE customs (201/204), пустой ключ → 400, чужой домен
→ 404, `customs` присутствует в ответе чека и не `null`; import отдаёт
`{created, removed}`
**Go, с Docker (testcontainers):**
- `internal/store` — add идемпотентен, delete снимает пометку, `ListCustomKeys`
скоуплен по домену, каскад при удалении домена; синхронизация импорта удаляет
только домены своего аккаунта и ничего не удаляет при пустом списке зон
**Фронт (Vitest + RTL):**
- секция `CUSTOMS` рендерится и не содержит чекбоксов
- «В customs» на строке prune дёргает мутацию с ключом записи
- «Вернуть в дифф» на строке custom дёргает мутацию удаления
- страница аккаунтов показывает «Создано N, удалено M» после импорта
## За рамками
- пометка по значению записи, а не по типу+имени
- массовое добавление в customs одним действием
- авто-очистка ключей, чьи записи исчезли из зоны (мусор безвреден: дифф их не
порождает, а `note`/`created_at` дают контекст при разборе)
- редактирование `note` из UI
- dry-run и подтверждение удаления orphan-доменов