# 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-доменов