2 lines
21 KiB
JSON
2 lines
21 KiB
JSON
{"index":131,"slug":"editorial-2024-05-mechanism-platform-templates","title":"Платформенный шаблон как контракт: базовый путь, расширение и отказ","excerpt":"Как превратить шаблон для команд в ограниченный контракт: отделить копию репозитория от наследования, закрыть входные параметры и остановить запрос, который требует отдельного архитектурного решения.","contentHtml":"<p>Команда просит создать внутренний HTTP-сервис. В форме есть имя, владелец и несколько флагов, поэтому старт выглядит безопасным. Через месяц в репозитории появляются ручные исключения в CI, другой healthcheck и особая политика хранения данных. Первый симптом обнаруживается в diff сразу после генерации: базовый шаблон уже не объясняет, почему проекту разрешены эти отличия.</p><p>Проблема не в самом генераторе файлов. Шаблон смешал три разных решения: какой класс компонента создаём, какие инварианты обязаны сохраниться и кто разрешает исключение. Из-за этого команда-потребитель принимает копию за наследника, а platform team получает набор неименованных вариантов без владельца обновлений. Чем шире форма, тем больше скрытых комбинаций приходится поддерживать.</p><p>Рабочая модель проще: шаблон принимает закрытый класс задач и возвращает один из трёх вердиктов. Полное совпадение с базовым контрактом ведёт в golden path. Одно заранее описанное отличие отправляется на review extension. Несовместимый или неизвестный вход даёт decline до создания репозитория. Отказ — нормальный результат валидатора, а не ошибка интерфейса.</p><h2>Симптом сначала, механизм потом</h2><p>Перед изменением шаблона зафиксируйте наблюдаемый случай. Например, заявка содержит <code>dataClass=internal</code>, runtime из разрешённого списка и расширение <code>observability-adapter</code>. Такой запрос можно сравнить с базовым контрактом. Другая заявка содержит внешний доступ и индивидуальный retention. Она меняет границу риска и не должна проходить под тем же названием.</p><table><caption>Как классифицировать заявку до генерации</caption><thead><tr><th>Наблюдение</th><th>Проверка</th><th>Вердикт</th><th>Что создаётся</th></tr></thead><tbody><tr><td>Все обязательные поля совпали с base contract</td><td>Тип компонента, owner, runtime, класс данных и доступ входят в разрешённые значения</td><td>Golden path</td><td>Скелет, metadata и стандартные проверки</td></tr><tr><td>Добавлено одно известное отличие</td><td>Есть identifier, владелец, граница, версия и условие удаления</td><td>Review extension</td><td>Proposal; публикация только после разрешения</td></tr><tr><td>Появился внешний доступ или другой класс данных</td><td>Сравнить инварианты и права; найти отдельный policy record</td><td>Decline</td><td>Только причина отказа и следующий вопрос</td></tr><tr><td>Поле или значение неизвестно</td><td>Проверить схему и список разрешённых action</td><td>Decline</td><td>Репозиторий не создаётся</td></tr></tbody></table><figure><img src='/assets/editorial/2024/platform-templates-2024-adoption-loop.svg' alt='Контур решения для платформенного шаблона: запись входа, сравнение с версионированным контрактом, golden path, именованное расширение или отказ, затем evidence и review'><figcaption>Сначала фиксируются факты и границы заявки, затем выбирается маршрут. Иллюстрация показывает порядок принятия решения, но не утверждает метрики внедрения или готовность конкретного репозитория.</figcaption></figure><h2>Шаблон, template-repository и fork — разные вещи</h2><p>Термин «шаблон» описывает и механизм, и начальный набор файлов, поэтому здесь легко ошибиться. GitHub пишет, что репозиторий, созданный из template, получает структуру и файлы выбранной ветки, а ветви такого репозитория имеют несвязанные истории. Это быстрый старт нового проекта, но не обещание получать изменения из исходного репозитория.</p><p>Fork отвечает на другой вопрос. Он сохраняет историю родительского репозитория и предназначен для работы с ним через общий граф коммитов. Если команде нужны pull request-ы из исходного проекта, template не заменяет fork. Если нужен самостоятельный сервис с начальным skeleton, fork создаёт лишнюю связь и другую модель владения.</p><p>Практическое правило: в описании платформы явно напишите, что распространяется после старта. Это может быть копия файлов, пакет, action, регулярные pull request-ы или самостоятельная версия. Само слово template не выбирает канал обновлений и не назначает владельца совместимости. Отсутствие такого договора и есть причина, по которой ручной patch позже принимают за «вариант шаблона».</p><h2>Закрытый контракт входных параметров</h2><p>Закрытый контракт отвечает на четыре вопроса: какой тип компонента создаётся, кто им владеет, какие значения разрешены и какое действие выполнится после проверки. Вход не должен принимать произвольный runtime-конфиг, credential, путь в файловой системе или политику retention. Каждый такой параметр расширяет область решений быстрее, чем растёт способность команды их проверять.</p><p>Ниже — учебный фрагмент для Backstage Software Template. В нём <code>enum</code> ограничивает класс данных и расширение, <code>additionalProperties</code> закрывает страницу формы, а <code>allowedHosts</code> и <code>allowedOrganizations</code> ограничивают место публикации. Имена организации, owner и runtime здесь проектные: их нужно заменить на значения своей Backstage-инсталляции.</p><pre><code>apiVersion: scaffolder.backstage.io/v1beta3\nkind: Template\nmetadata:\n name: internal-http-service\nspec:\n owner: platform/catalog\n type: service\n parameters:\n - title: Service contract\n required: [name, owner, dataClass]\n additionalProperties: false\n properties:\n name:\n title: Service name\n type: string\n pattern: '^[a-z][a-z0-9-]{2,30}$'\n owner:\n title: Owner group\n type: string\n ui:field: OwnerPicker\n dataClass:\n title: Data class\n type: string\n enum: [internal]\n extension:\n title: Extension\n type: string\n enum: [none, observability-adapter]\n default: none\n - title: Repository\n required: [repoUrl]\n properties:\n repoUrl:\n title: Repository location\n type: string\n ui:field: RepoUrlPicker\n ui:options:\n allowedHosts: [github.com]\n allowedOrganizations: [acme-platform]\n steps:\n - id: fetchBase\n name: Fetch base\n action: fetch:template\n input:\n url: ./skeleton\n values:\n name: ${{ parameters.name }}\n owner: ${{ parameters.owner }}\n extension: ${{ parameters.extension }}\n - id: publish\n name: Publish\n action: publish:github\n input:\n repoUrl: ${{ parameters.repoUrl }}</code></pre><p>Это не готовая policy организации. Backstage проверит форму и выполнит описанные actions, но не узнает сам, разрешён ли owner, достаточно ли прав у пользователя, соответствует ли healthcheck требованиям команды и можно ли откатить опубликованный ресурс. Эти инварианты должны проверяться отдельным action, permission policy, CI или review перед публикацией.</p><p>Проверка отрицательного пути обязательна. Подставьте <code>dataClass=customer</code>, неизвестное значение <code>extension</code>, пустой owner и лишний ключ. Ожидаемый результат — ошибка схемы до шага <code>publish</code>. Если неизвестный вход всё-таки доходит до публикации, закрытая форма существует только на экране, а не в контракте.</p><h2>Base path и extension point</h2><p>Base path — повторяемая часть решения. В нашем случае он фиксирует внутренний HTTP-сервис, назначенную группу-владельца, разрешённый runtime, внутренний класс данных и минимальный набор проверок. Версия base contract должна быть видна в документации или metadata. Иначе невозможно понять, к какой схеме относится сгенерированный проект.</p><p>Extension point разрешает узкое отличие от этой схемы. Для расширения заранее запишите пять полей: <code>identifier</code>, owner, границу изменения, версию и условие удаления. Observability adapter подходит как пример, если он добавляет известные метрики и не меняет доступ, класс данных или способ хранения. Внешний endpoint, новый runtime и другая retention policy уже меняют инвариант.</p><p>Такое разделение помогает не спорить о названиях. Сначала сравните заявку с canonical record. Затем проверьте, что отличие не пересекает границу base path. Если пересекает, верните decline и сформулируйте отдельный архитектурный вопрос. Если не пересекает, создайте review record, а не молча добавьте условие в основной шаблон.</p><h2>Воспроизводимая проверка до публикации</h2><p>У Backstage есть Template Editor с dry-run режимом. Он позволяет загрузить локальный каталог шаблона, заполнить форму и посмотреть результат выполнения actions без сохранения изменений в рабочем шаблоне. Это удобное место для проверки маршрутов, но dry-run не заменяет permission check, credentials, сетевые права и итоговый pipeline.</p><ol><li><strong>Сохраните контракт.</strong> Зафиксируйте версию, обязательные поля, допустимые enum и инварианты, которые нельзя менять extension-ом.</li><li><strong>Запустите базовый dry-run.</strong> Передайте короткое имя, существующую группу, <code>internal</code> и <code>none</code>. Проверьте, что skeleton содержит ожидаемые metadata и список проверок.</li><li><strong>Запустите разрешённое расширение.</strong> Замените только <code>extension</code> на <code>observability-adapter</code>. Сравните diff и убедитесь, что не появились внешний доступ, другой runtime или новая политика хранения.</li><li><strong>Проверьте отказ.</strong> Подайте неизвестное значение, внешний класс данных и лишний ключ. Каждый случай должен остановиться до <code>publish:github</code> и вернуть понятную причину.</li><li><strong>Проверьте повтор.</strong> Повторите базовый запуск с теми же параметрами. Убедитесь, что генератор не создаёт второй обязательный action, дублирующую регистрацию или неожиданный ресурс.</li><li><strong>Проверьте реальную публикацию отдельно.</strong> После review подтвердите credentials, branch protection, CI, healthcheck, наблюдаемость и откат в окружении команды. Результат dry-run нельзя выдавать за результат production-развёртывания.</li></ol><p>Для различия template и fork можно дополнительно проверить историю после создания репозитория. URL замените на адрес своего исходного template:</p><pre><code>git clone https://github.com/acme-platform/internal-http-template.git template\ngit clone https://github.com/acme-team/payments-api.git generated\ngit -C generated remote add template https://github.com/acme-platform/internal-http-template.git\ngit -C generated fetch template main\nif git -C generated merge-base --is-ancestor template/main HEAD; then\n echo 'generated наследует историю template'\nelse\n echo 'истории не связаны: обновления нужно доставлять отдельным механизмом'\nfi</code></pre><p>Команда проверяет именно граф коммитов, а не похожесть файлов. Для репозитория, созданного из GitHub template, ожидается ветка с несвязанной историей. Если команда требует постоянного наследования, зафиксируйте другой механизм: fork, пакет, генератор обновлений или pull request-ы с отдельным owner.</p><h2>Отказ и границы применимости</h2><p>Шаблон не назначает владельца за команду, не проводит threat modeling, legal review или capacity planning. Он не определяет SLO, не доказывает безопасность secrets и не решает, сколько стоит сопровождение внешней интеграции. Backstage предоставляет схему, actions и точки авторизации, а GitHub — механизм создания репозитория; policy конкретной организации остаётся за её владельцами.</p><p>Отказ должен быть информативным и безопасным. Сообщение вроде <code>decline-template: внешний доступ меняет границу data class; назначьте policy owner и создайте отдельный design record</code> объясняет следующий шаг. Репозиторий не создаётся, credentials не расходуются, а заявку можно вернуть после принятия решения. Удалять уже опубликованный чужой ресурс ради отката шаблона нельзя считать безопасным default.</p><p>Не расширяйте golden path ради редкого случая. Если одно и то же отличие повторяется, сохраняет исходные инварианты и проходит review, включите его в новую версию контракта. Если отличие одноразовое или требует другого доступа, оставьте его отдельным решением. Так количество вариантов отражает реальные договорённости, а не историю случайных ручных patch-ей.</p><h2>Критерий готовности</h2><p>Шаблон готов к использованию, когда две команды получают одинаковый вердикт по одинаковым фактам. Базовая заявка проходит закрытую схему. Разрешённое расширение имеет owner, границу, версию и rollback. Несовместимый запрос останавливается до публикации. Повторный запуск имеет заранее определённое поведение.</p><p>Проверяйте не количество созданных репозиториев, а четыре маршрута: base, разрешённое extension, неизвестный input и повтор base. Для каждого сохраните вход, вердикт, созданные артефакты и причину отказа. Такой набор даёт команде воспроизводимый контракт и показывает, где шаблон заканчивается. Всё, что требует нового класса данных, доступа или жизненного цикла, должно выйти за эту границу явно.</p><h2>Проверяемые источники</h2><ul><li><a href='https://backstage.io/docs/features/software-templates/' target='_blank' rel='noopener noreferrer'>Backstage: Software Templates</a> — назначение шаблонов, параметры, шаги, dry-run и результаты выполнения.</li><li><a href='https://backstage.io/docs/features/software-templates/writing-templates/' target='_blank' rel='noopener noreferrer'>Backstage: Writing Templates</a> — структура <code>apiVersion</code>, <code>spec.parameters</code>, <code>spec.steps</code>, actions и условия выполнения шагов.</li><li><a href='https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template' target='_blank' rel='noopener noreferrer'>GitHub Docs: Creating a repository from a template</a> — различия template и fork, включая несвязанные истории ветвей.</li></ul>"}
|