{ "index": 95, "slug": "editorial-2025-05-mechanism-engineering-automation", "title": "Почему preview и approval не делают автоматизацию безопасной", "excerpt": "Preview показывает рассчитанное намерение, authority ограничивает допустимый scope, approval связывает согласие с конкретной операцией, а verification читает результат. Если эти доказательства смешать, batch-автоматизация может изменить лишние объекты.", "contentHtml": "
Симптом обнаруживается после зелёного pipeline: preview показал ожидаемый diff, а reviewer нажал approve. После запуска изменились записи за пределами заявки. В журнале есть operation id, но из него не видно, какие объекты реально изменились. Команда тратит часы на восстановление списка целей и рискует запустить ещё один широкий change под видом rollback.
\nПричина не в отсутствии ещё одной кнопки подтверждения. Разные этапы отвечают на разные вопросы: preview описывает предложение в определённый момент, authority задаёт предел полномочий, approval фиксирует согласие, audit trail сохраняет событие, а verification читает целевое состояние после записи. Без явной связи между ними зелёный статус доказывает меньше, чем кажется.
\nНачните с разделения доказательств. Это не бюрократия, а способ не выдать одно наблюдение за другое. Наличие preview не говорит, что scope разрешён. Approval не говорит, что target list остался прежним. Receipt от executor не доказывает, что целевая система сохранила значение.
\n| Артефакт | Честное утверждение | Опасная подмена | Следующая проверка |
|---|---|---|---|
| Preview | Для зафиксированного snapshot рассчитан proposed change | Execute даст тот же эффект | Проверить свежесть и preconditions перед записью |
| Authority | Policy разрешает selector и размер scope | Change полезен и технически корректен | Сверить intent и содержимое preview |
| Approval | Reviewer разрешил связанную карточку в своей роли | Согласие переносится на похожую операцию | Сравнить operation, proposal, target и authority digests |
| Audit trail | Executor записал событие по своему контракту | Target system уже в ожидаемом состоянии | Прочитать authoritative source после execute |
| Verification | Reader наблюдал заданное expected state | Все побочные последствия устранены | Проверить residual risk и план recovery |
В карточке операции храните как минимум идентификатор, selector, точный список targets, digest списка, ожидаемое состояние до и после, версию логики изменения и срок действия. Человеческое описание остаётся полезным для review, но executor должен сравнивать машинные поля буквально.
\nPreview устаревает, когда меняется объект, inventory или policy. Поэтому его ценность ограничена snapshot version и временем расчёта. Если между preview и execute другой job обновил target, старый diff больше не является описанием текущего действия. В безопасном процессе есть явное условие: пересчитать предложение или остановиться, если precondition больше не выполняется.
\nЭто соответствует модели Terraform. Официальная документация описывает terraform plan как расчёт изменений без их выполнения. План без -out является speculative plan и не содержит намерения применяться. Документация отдельно предупреждает, что изменения в target system между ранним speculative plan и финальным применением могут изменить результат; перед apply нужно снова проверить актуальный non-speculative plan. Это узкий факт о Terraform, а не готовая гарантия для любого самописного runner.
Сохраняйте не только красивый diff. Практический минимум выглядит так: operationId, snapshotVersion, selector, dense target list, targetDigest, expected precondition и proposalDigest. Digest здесь связывает две фазы и позволяет обнаружить расхождение. Он не заменяет криптографическую подпись, проверку identity или защиту хранилища.
Слово dry-run описывает режим, но не обещает одинаковый объём проверки. В официальной reference для kubectl apply режим --dry-run=client только печатает объект, который был бы отправлен, без отправки. Режим --dry-run=server передаёт запрос на сервер, но не сохраняет ресурс. Значит, client mode проверяет локальную подготовку объекта, а server mode проходит часть серверного пути. Ни один режим сам по себе не подтверждает будущий persistent change.
В карточке записывайте источник данных и границу побочного эффекта: «локальная модель, внешний read не выполняется», «server admission, сохранение запрещено» или «read-only inventory со snapshot». Тогда reviewer понимает, какую проверку ещё нужно сделать. Если этого поля нет, слово preview создаёт ложное ощущение полноты.
\nAuthority — это машинная граница: допустимый selector, максимальное число targets, срок действия и исключения. Она отвечает на вопрос «можно ли этой роли работать с таким scope», но не решает, правильно ли менять поле. Если policy разрешает два объекта, карточка с тремя должна остановиться до approval. Список нельзя молча уменьшать: reviewer должен видеть ровно тот scope, который получит executor.
\nСравнивайте exact selector и exact target digest. Не подменяйте их словами «маленький batch» или «почти тот же список». Не расширяйте authority, если часть целей исчезла из inventory. Не превращайте превышение лимита в warning, когда последующая операция может затронуть больше объектов, чем видел reviewer.
\nНиже — автономный пример на Node.js. Он работает только с объектами в памяти, не читает сеть, не проверяет реальные права и ничего не меняет во внешней системе. В нём три synthetic target, authority разрешает два, а approval связан с точным operation и target digest. Скопируйте блок в shell с Node.js 18+:
\nnode --input-type=module <<'NODE'\nconst operation = {\n id: 'op-normalize-labels-v1',\n selector: 'labels.env=staging',\n targets: ['service-a', 'service-b', 'service-c'],\n targetDigest: 'targets-a-b-c-v1',\n proposalDigest: 'proposal-7f2a'\n};\n\nconst authority = {\n id: 'authority-l2-v1',\n selector: 'labels.env=staging',\n maxTargets: 2\n};\n\nconst approval = {\n operationId: 'op-normalize-labels-v1',\n targetDigest: 'targets-a-b-c-v1',\n proposalDigest: 'proposal-7f2a',\n authorityId: 'authority-l2-v1'\n};\n\nfunction gate(op, policy, decision) {\n const reasons = [];\n if (op.selector !== policy.selector) reasons.push('selector-not-authorized');\n if (op.targets.length > policy.maxTargets) reasons.push('scope-exceeds-authority-limit');\n if (decision.operationId !== op.id\n || decision.targetDigest !== op.targetDigest\n || decision.proposalDigest !== op.proposalDigest\n || decision.authorityId !== policy.id) {\n reasons.push('approval-does-not-bind-operation');\n }\n return reasons.length === 0\n ? { status: 'READY_FOR_EXECUTE', operationId: op.id }\n : { status: 'STOP', reasons };\n}\n\nconsole.log(gate(operation, authority, approval));\n// { status: 'STOP', reasons: [ 'scope-exceeds-authority-limit' ] }\n// No external target is read or changed.\nNODE\nКоманда возвращает STOP, потому что три цели превышают лимит два. Чтобы проверить вторую отрицательную ветку, замените в approval значение targetDigest на targets-a-c-v1: результат дополнится approval-does-not-bind-operation. В настоящей системе digest вычисляет канонизатор данных, authority приходит из доверенного policy engine, а identity и срок решения проверяются отдельно.
Approval должен содержать operation id, proposal digest, target digest и authority id. Полезны также роль reviewer, decision, reason и expiry. Executor сравнивает эти значения перед write; «похожий» selector или частичное совпадение не подходят. Если approval относится к targets A и C, а preview — к A и B, процесс возвращает stop и требует новый preview.
\nУ GitHub Environments есть похожая, но более узкая граница: job, который ссылается на environment, должен пройти настроенные protection rules до запуска или доступа к environment secrets. В документации required reviewers и запрет административного bypass описаны как отдельные настройки. Это пример gate запуска, а не доказательство корректности diff. Доступность функций зависит от типа репозитория и плана GitHub, поэтому переносить его поведение в собственный runner без проверки нельзя.
\nBypass тоже является событием с владельцем, причиной и audit record. Если исключение скрыто внутри общего поля approved=true, расследование не отличит обычное согласие от обхода. Для destructive operation отсутствие reviewer или истёкший expiry должны вести к stop, а не к default-решению.
Audit trail восстанавливает причинную цепочку: operation id, версии runner и policy, selector, digests, authority, approval, время и результат по каждой цели. Но запись «executor отправил запрос» — это receipt. Она не подтверждает, что target system приняла значение, что controller не откатил его сразу и что побочный объект не изменился.
\nVerification начинается с ожидаемого состояния. Например: authoritative reader видит label env=staging на двух конкретных targets, digest списка совпадает, а отсутствующие объекты перечислены. У проверки должны быть три различимых исхода: matched, mismatched и unknown. Недоступный reader — это unknown, а не «успех с предупреждением».
| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| Изменились лишние объекты | Selector расширился или scope не был связан с approval | Сравнить target digest до write и фактический список | Остановить цепочку и собрать bounded inventory |
| Preview и execute показывают разный diff | Target state изменился между фазами | Сверить snapshot version, precondition и возраст preview | Пересчитать preview, старое approval не использовать |
| В audit есть success, но поле прежнее | Receipt выдали за verification | Прочитать authoritative source отдельным путём | Вернуть mismatched или unknown |
| Client dry-run прошёл, сервер отказал | Локальная проверка не видела server-side policy | Сравнить режим dry-run и ответ admission | Исправить вход или остановиться до нового review |
| Rollback снова меняет не те targets | Обратный write использует старый scope | Проверить observed state и новую authority | Оформить rollback как новую операцию |
После частичного результата нельзя считать исходное значение безопасной обратной командой. Человек мог исправить часть targets, исходное состояние могло стать устаревшим, а новая policy может запретить обратную запись. Поэтому первый шаг — observed inventory: что изменилось, где состояние неизвестно, какие зависимости затронуты и кто владеет recovery.
\nЗатем rollback проходит тот же bounded маршрут: новый scope, новые preconditions, новая authority, новый preview, отдельный approval и последующая verification. Статус «rollback planned» не означает «rollback executed». Автоматический inverse в блоке finally опасен именно потому, что исполняется без свежего знания о состоянии.
Схема не создаёт identity provider, policy engine, неизменяемое хранилище журнала, распределённую блокировку, гарантию идемпотентности или реальный rollback. Учебный код не доказывает права пользователя, свежесть inventory, криптографическую стойкость digest и фактическое состояние production. Он проверяет только связь полей и отказ до внешней записи.
\nДаже независимая verification имеет границу: reader может быть устаревшим, неполным или не видеть побочные системы. Для каждого change назовите источник истины, допустимую задержку и владельца unknown. Для удаления и других необратимых действий добавьте отдельные меры: резервную копию, лимит скорости, ручное подтверждение и план восстановления.
\nДокументация Terraform, Kubernetes и GitHub меняется; флаги и доступность функций нужно сверять с версией и тарифом своего окружения. Примеры в статье иллюстрируют узкие свойства официальных инструментов, а не сертифицируют единый безопасный процесс. Готовность определяется собственными тестами: scope выше лимита останавливается, чужой digest не проходит, недоступный reader остаётся unknown, а старое approval не запускает rollback.
\nplan, speculative plan, сохранённый план через -out и предупреждение о повторной проверке после изменений в target system.--dry-run=client и --dry-run=server, включая отсутствие сохранения ресурса в server mode.