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:
@@ -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-доменов
|
||||
Reference in New Issue
Block a user