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
14 KiB
Custom-записи в диффе и очистка доменов удалённых зон
Дата: 2026-08-19 Статус: утверждён к реализации
Задача
Две связанные проблемы эксплуатации, обе всплывают на странице DOMAIN / CHECK.
-
Осознанные записи вне шаблона. Оператор добавляет в зону запись, которой нет в шаблоне (
CNAME admin.example.ru.,app,dav). Каждый чек кладёт её вPRUNES, домен получает статусdrift, планировщик шлёт уведомление. Убрать шум можно только внеся запись в шаблон — но шаблон общий для многих зон, а запись специфична для одной. Нужна возможность пометить такую запись «это осознанно, не проверять», с обратимым действием. -
Домены исчезнувших зон.
ImportDomainsтолько создаёт домены (ON CONFLICT DO NOTHING). Если зона удалена у провайдера, домен остаётся в БД навсегда: планировщик чекает его, получает ошибку провайдера, домен висит в статусеerrorи генерирует уведомления. Переимпорт ситуацию не лечит.
Принятые решения
| Вопрос | Решение |
|---|---|
| Скоуп custom-записей | Привязка к домену. Исключение действует только в своей зоне |
| Конфликт с шаблоном | Шаблон побеждает: пометка действует только на Kind == Delete |
| Хранение | Отдельная таблица domain_custom_records |
| Orphan-домены | Удаляются в транзакции переимпорта, с гардом на пустой ответ провайдера |
Часть 1. Custom-записи
Модель данных
Миграция 0005_domain_custom_records.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 NOTHINGDeleteCustomRecord—DELETE WHERE domain_id = $1 AND record_key = $2ListCustomKeys—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:
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 на фронте):
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 переименовывается по смыслу в синхронизацию: в одной транзакции
- создаются домены для новых зон (как сейчас,
ON CONFLICT DO NOTHING); - удаляются домены этого
provider_account_id, чьихzone_idнет в ответе провайдера.
Скоуп удаления — аккаунт, а не проект: у проекта может быть несколько provider-аккаунтов, и зоны одного не должны влиять на домены другого.
Гард на пустой ответ
Если ListZones вернул ноль зон — не удаляется ничего. Пустой список
неотличим от «аккаунт временно потерял доступ к зонам», а ценой ошибки будет
удаление всех доменов аккаунта вместе с историей чеков. Ошибка ListZones
поднимается наружу и до store не доходит, так что случай «частичный список без
ошибки» остаётся только теоретическим (в клиенте Selectel — при неувеличивающемся
next_offset); гард на пустой ответ закрывает практически значимую часть риска.
Что удаляется каскадом
check_runs (история чеков) и domain_custom_records — обе таблицы ссылаются на
domains с ON DELETE CASCADE. Привязка шаблона у удаляемого домена просто
исчезает; сам шаблон не трогается. Операция необратима — поэтому и нужен гард.
Контракт ответа
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, когда единственное расхождение — custominternal/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-доменов