Files
dns-autoresolver/docs/superpowers/specs/2026-08-19-custom-records-and-orphan-cleanup-design.md
vasyanskandClaude Opus 5 eead9f3f3d 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
2026-08-19 17:42:53 +07:00

14 KiB
Raw Permalink Blame History

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:

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:

  • AddCustomRecordINSERT ... ON CONFLICT (domain_id, record_key) DO NOTHING
  • DeleteCustomRecordDELETE WHERE domain_id = $1 AND record_key = $2
  • ListCustomKeysSELECT 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 переименовывается по смыслу в синхронизацию: в одной транзакции

  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. Привязка шаблона у удаляемого домена просто исчезает; сам шаблон не трогается. Операция необратима — поэтому и нужен гард.

Контракт ответа

type importResponse struct {
    Created []domainResponse `json:"created"`
    Removed []domainResponse `json:"removed"`
}

Это ломающее изменение: сейчас ручка отдаёт голый массив созданных доменов. Фронт правится в том же коммите, иначе задеплоенный бандл получит объект там, где ждёт массив. UI показывает «Создано N, удалено M».

Тесты

Go, без Docker:

  • internal/diffMarkCustom метит только Delete; шаблон побеждает (ключ, описанный шаблоном, остаётся в Updates); Actionable/Updates/Prunes исключают Custom; Customs() возвращает помеченные
  • internal/serviceresolve метит диффы ключами из 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-доменов