diff --git a/docs/superpowers/specs/2026-08-19-custom-records-and-orphan-cleanup-design.md b/docs/superpowers/specs/2026-08-19-custom-records-and-orphan-cleanup-design.md new file mode 100644 index 0000000..6286c1a --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-custom-records-and-orphan-cleanup-design.md @@ -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-доменов