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