软件模板与 Golden Path:把自助创建变成可补偿的工程事务
开发者在门户里点了一次“创建服务”,页面转了很久,于是又点一次。几分钟后,Git 里出现两个仓库,云账号里留下一个没有 Owner 的测试数据库,密钥系统里还有一条从未被应用读取的凭证。平台团队看到的却只是一个红色任务:最后的目录登记步骤超时。这个事故不是表单做得不够漂亮,而是把跨 Git、云、CI、密钥系统的多步副作用误当成了一次普通函数调用。
软件模板把团队反复执行的建仓、生成骨架、注册目录、申请流水线和资源接入组合成一条 Golden Path。Golden Path 的“金”不在于选项少,而在于默认路径带着安全约束、可观测证据和退出动作;开发者可以自助,平台仍然知道是谁以什么版本、什么参数创建了哪些对象,失败后应继续、重试还是清理。
把这条链落到 Backstage 时,版本与产品形态会直接影响操作方式。当前稳定主线为 v1.53.0,仓库采用 Apache License 2.0;它是需要组织自行部署、升级和集成的开源框架,不自带托管 SLA、商业支持、审批系统或云资源事务。商业发行版和第三方插件的许可、支持与数据驻留要另按合同核对。Backstage 的 umbrella release 只是经验证的一组包版本,主线编号不遵循 SemVer,常规小版本也可能包含破坏性变化;next 是预发布线,/alpha、/beta 导出的兼容承诺也弱于公开导出。生产应用应固定整条 release line,逐条阅读 changelog 的 BREAKING 与弃用迁移说明,而不是只升级某个 Scaffolder 包。官方的版本策略和开源仓库分别给出发布承诺与许可证事实。
先分清模板、动作和外部事务
一份模板通常包含三层对象。参数 Schema 是调用合同,回答“允许输入什么”;步骤图是编排合同,回答“以什么条件调用哪些动作”;动作是副作用适配器,负责真正访问 Git、云、Kubernetes、CI 或密钥系统。Backstage 的 Software Templates 使用 Template 实体表达参数与步骤,每次执行形成独立 task;内置或自定义 action 执行取模板、渲染、发布和登记等操作。官方的模板编写说明列出了 spec.parameters、spec.steps、if、each、secrets schema、状态检查函数和编辑器 dry-run 的行为。
scaffolder.backstage.io/v1beta3 是当前稳定文档使用的模板实体 API,名称中的 beta 不能推导出整套动作都处于同一稳定等级。模板从受信 Catalog location 摄取,动作则是运行在后端主机上的代码。Backstage 威胁模型明确建议限制 Template、User、Group 的注册来源并审计每个已安装 action;若普通内部用户可以登记任意模板,即使 action 本身没有代码执行漏洞,也可能借高权集成修改或删除外部资源。
这三层不能混成一段脚本。Schema 通过不表示操作者有权创建目标资源,动作成功也不表示整个服务已经可用。Git 仓库返回 201 后,后续云资源创建仍可能失败;请求超时后,仓库也可能已经创建成功。模板引擎没有跨供应商数据库事务,所谓“事务”必须由业务幂等、状态持久化、外部对账和补偿动作共同构造。
先为每次申请建立稳定身份。推荐把 requestId 视为一次业务意图的幂等键,而不是每次 HTTP 请求的随机数。用户刷新页面或从失败任务重新开始时,应复用原键;用户明确改变服务名、Owner 或环境并提交新意图时,才产生新键。外部对象再带上 managed-by=portal、request-id=<id>、template-version=<digest> 等标签,后续对账才能区分“本次创建”“早已存在”和“同名但不归模板管理”。
用参数 Schema 阻断危险输入
表单字段不是装饰。type 决定值域,required 决定创建任务前必须具备的事实,pattern 和 enum 缩小命名与环境选择,default 会改变所有未主动选择用户的行为,ui:* 只影响呈现而不能承担安全校验。服务名若直接进入仓库名、DNS、资源标签和包名,必须取各下游约束的交集;Owner 应使用目录实体选择器并在后端重新确认关系,不能相信浏览器提交的任意字符串。
下面是一段可作为起点的 Backstage v1beta3 模板。它把普通参数和 secret 分开,并让生产环境选择显式出现。示例里的凭证是占位符,真实值应在任务创建时由受信任调用方注入,不能写进模板仓库。
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: service-golden-path-v3
title: 创建标准服务
spec:
owner: group:default/platform
type: service
secrets:
schema:
required: [SCM_TOKEN]
properties:
SCM_TOKEN:
type: string
description: 仅用于本次发布仓库
parameters:
- title: 服务身份
required: [name, owner, environment, requestId]
properties:
name:
type: string
pattern: '^[a-z][a-z0-9-]{2,30}$'
owner:
type: string
ui:field: OwnerPicker
ui:options:
allowedKinds: [Group]
environment:
type: string
enum: [development, staging, production]
default: development
requestId:
type: string
pattern: '^gp-[a-f0-9]{16}$'
steps:
- id: fetchBase
name: 生成工作区
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
- id: publishRepo
name: 发布仓库
action: publish:github
input:
repoUrl: github.com?owner=your-org&repo=${{ parameters.name }}
token: ${{ secrets.SCM_TOKEN }}
- id: registerCatalog
name: 登记组件
action: catalog:register
input:
repoContentsUrl: ${{ steps.publishRepo.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yamlSchema 还应表达跨字段约束:生产环境需要成本中心与审批引用,公开仓库不能选择内部数据等级,数据库规格要受环境上限约束。JSON Schema 能处理一部分 if/then,更复杂的组织规则应在后端 action 前置检查中执行。原因很简单:攻击者可以绕过 UI 直接调用任务接口,而表单隐藏、禁用按钮、前端 feature flag 都不是授权。
认证与授权还要拆开。新后端的默认 auth policy 要求用户或服务凭证,不应在生产设置 backend.auth.dangerouslyDisableDefaultAuthPolicy;但“请求已认证”仍不代表“允许执行模板”。Scaffolder 的授权说明提供任务创建、读取、取消、参数/步骤读取和 actionExecutePermission。策略至少要让普通用户只能读取和取消自己的任务,高风险 action 按 action ID、输入目标、用户组与环境做条件授权,模板发布者与 action 安装者再分权。UI 隐藏步骤只是减少可见性,最终拒绝必须发生在后端 permission policy 和外部系统权限处。
正向验证应提交一个合法开发环境参数集,证据是任务成功进入 Planned,规范化后的参数包含明确 Owner、环境和模板摘要。反向验证至少包含非法名称、无效 Owner、生产环境缺审批三组;预期是在任何外部调用前返回结构化拒绝,Git 审计中没有建仓请求,云账单和密钥列表都不变化。
先 dry-run,再允许真实副作用
dry-run 不是“假装成功”,而是把模板解析、参数计算、文件渲染和动作计划暴露出来。Backstage 模板编辑器可以在 dry-run 模式展示文件系统结果与动作日志,但只有声明支持 dry-run 的 action 才能提供可信结果。自定义 action 若在 dry-run 中仍调用真实云 API,名称再好听也会造成副作用;若一律返回成功,又会掩盖输入、权限和配额错误。
自定义 action 只有显式声明 supportsDryRun: true 才应进入 dry-run;声明本身不是安全证明。动作测试要注入一个在写请求时立即失败的网络适配器,执行 dry-run 后断言写调用计数为零,同时断言计划中包含目标、权限和预估成本。反向用例故意保留一次 POST,预期测试失败并显示具体 action ID;清理时删除临时工作区、撤销只读试验凭证并确认外部审计无写事件。这样验证的是“没有副作用”,而不是页面上出现了 dry-run 标签。
把动作分成四类更容易决定 dry-run 深度:纯函数动作可以完整执行;文件工作区动作可以在临时目录执行并输出差异;远程读取可以使用只读凭证查询名称冲突、配额和策略;远程写入只生成计划,显示目标、请求摘要、所需权限和预计成本,不提交写请求。计划中敏感字段只显示“已提供”和来源类型,不回显值。
一次可信的 dry-run 应留下:模板内容摘要、动作实现版本、规范化参数摘要、预计创建对象、名称冲突、权限预检结果、成本等级、不可逆动作以及计划过期时间。计划与执行之间若模板、策略或关键外部状态变化,执行应重新校验或拒绝使用旧计划。否则审核人看到的是低风险规格,实际执行的已是另一套模板。
本地启用模板能力时,可以从已有 Backstage 应用安装 Scaffolder 后端和实际需要的 action 模块。下面以 GitHub 发布模块为例;先按应用锁文件确认 Backstage 包处在同一 release line,再执行安装,不要把不同主线的插件临时拼在一起:
yarn --cwd packages/backend add \
@backstage/plugin-scaffolder-backend \
@backstage/plugin-scaffolder-backend-module-github在新后端入口 packages/backend/src/index.ts 中加入 backend.add(import('@backstage/plugin-scaffolder-backend')) 和 GitHub action module,启动后访问 /create/actions,确认只出现经过批准的 action。随后导入一个只含 fetch:template、debug:log 的无副作用模板,打开 /create/edit,加载模板目录,填入参数并执行 dry-run。预期结果是抽屉中出现渲染文件和逐步日志,Git 与云端没有新对象。若 action 显示为“不支持”或 schema 校验失败,先修正 action 注册、输入字段和模板 API 版本,不要直接切到真实发布来碰运气。官方的内置 action 安装说明同时给出了模块注册和 action 列表入口。
以副作用账本实现幂等
幂等不是“看到 409 就当成功”。同名仓库可能由另一个团队手工创建,配置也可能与请求不一致。动作开始前应以 requestId + actionId + target 查询副作用账本;命中 SUCCEEDED 时返回已保存输出,命中 RUNNING 且租约未过期时拒绝并发,命中超时状态时先向外部系统对账。只有外部对象的管理标签、Owner、模板摘要都符合预期,才能把未知结果收敛为成功。
账本至少保存 request_id、task_id、action_id、target_ref、desired_hash、external_id、status、lease_until、attempt、created_by、created_at 与脱敏后的输出摘要。它不能只存在于任务进程内存,因为进程恰恰会在网络超时、滚动升级和节点故障时丢失。数据库唯一约束应覆盖业务幂等键;外部 API 支持 idempotency key 时继续传递同一个键,但不能把供应商的短期去重窗口当成永久账本。
下面的本地模型可以直接用 Node.js 运行,不依赖 SaaS。第一次调用创建仓库并故意在目录登记处失败,补偿删除仓库;第二次用同一幂等键重试并成功;第三次顺序重放返回已保存结果。它缩小了真实系统,却保留了失败补偿、同键重试与成功重放三类关键状态转折。生产环境的并发租约仍需用持久化数据库、唯一约束和可控竞争实验单独证明,不能由这个串行内存模型代替。
const ledger = new Map();
const repos = new Set();
async function run({ key, failRegister = false }) {
const old = ledger.get(key);
if (old?.status === 'SUCCEEDED') return `replay:${old.repo}`;
if (old?.status === 'RUNNING') throw new Error('409_IN_PROGRESS');
ledger.set(key, { status: 'RUNNING' });
const repo = `your-org/service-${key.slice(-4)}`;
try {
repos.add(repo);
ledger.set(key, { status: 'REPO_CREATED', repo });
if (failRegister) throw new Error('CATALOG_TIMEOUT');
ledger.set(key, { status: 'SUCCEEDED', repo });
return `created:${repo}`;
} catch (error) {
repos.delete(repo);
ledger.set(key, { status: 'ROLLED_BACK', reason: error.message });
throw error;
}
}
(async () => {
await run({ key: 'gp-0123456789abcdef', failRegister: true }).catch(e =>
console.log('negative', e.message, [...repos], ledger.get('gp-0123456789abcdef')),
);
console.log('positive', await run({ key: 'gp-0123456789abcdef' }));
console.log('replay', await run({ key: 'gp-0123456789abcdef' }));
})();预期输出的关键特征是:反向路径出现 CATALOG_TIMEOUT,仓库集合为空,状态为 ROLLED_BACK;正向重试只创建一个仓库并进入 SUCCEEDED;再次重放返回 replay,不增加对象。生产实现还要加入持久化事务、租约时钟、对账退避和指标,不能照搬内存 Map。
仓库、云资源和密钥按风险排序
模板步骤顺序应尽量“先验证、后写入;先便宜、后昂贵;先可逆、后不可逆”。先解析参数、授权、配额与命名冲突,再渲染和扫描骨架;创建仓库后再配置分支保护、CI 与目录;昂贵云资源在必要审批完成后创建;数据库初始密码、Webhook secret 等敏感对象尽量由目标系统生成并直接绑定工作负载,不经过普通参数、任务日志或模板输出。
仓库创建动作要区分 create、adopt 和 reconcile。create 只接管由本请求新建的对象;adopt 必须有独立高权限审批,验证现有仓库 Owner、内容和保护规则;reconcile 只修复明确归模板管理的字段。把“同名已存在”自动视为成功会劫持别人的仓库,把“全部字段强制一致”又可能覆盖服务团队的合法自定义。
云资源需要预算护栏。参数 Schema 不直接暴露任意实例规格、地域和网络 ID,而引用经过评审的 offering;后端根据环境、数据等级、成本中心和配额映射成真实 IaC 输入。任务输出保存资源 ID 和工单引用,不保存云密钥。创建后立刻写 Owner、过期时间、请求 ID 和数据等级标签;缺少这些标签应让任务进入待人工处理,而不是显示完整成功。
凭证最小化有三条线。第一,平台身份只拥有“为获准目标申请临时身份”的能力,不持有跨组织永久管理员 Token。第二,每个 action 获得与动作、目标和时长绑定的短期凭证,读仓库的动作拿不到删仓库权限。第三,凭证不进入模板参数普通字段、渲染文件、任务输出和异常对象。Backstage secrets schema 可以在任务创建前验证 secret 是否存在,但 schema 不会自动缩小 secret 的权限,也不会阻止自定义 action 把值写进日志。
失败补偿要承认不可逆性
Backstage 的状态检查函数允许后续步骤用 ${{ failure() }} 在前序失败时运行,或用 ${{ always() }} 无论成功失败都执行审计。默认情况下,失败后的普通步骤会跳过;取消信号也只有在当前 action 支持时才能中止当前步骤。因此,补偿动作必须显式存在,且 action 要尊重 abort signal。仅在页面显示“已取消”不能证明外部云 API 停止。
steps:
- id: createDatabase
name: 创建临时数据库
action: your-org:cloud:createDatabase
input:
requestId: ${{ parameters.requestId }}
offering: ${{ parameters.databaseOffering }}
- id: registerCatalog
name: 登记服务
action: catalog:register
input:
catalogInfoUrl: ${{ steps.publishRepo.output.repoContentsUrl }}/catalog-info.yaml
- id: cleanupDatabase
name: 清理未承诺数据库
action: your-org:cloud:deleteIfOwned
if: ${{ failure() }}
input:
requestId: ${{ parameters.requestId }}
resourceId: ${{ steps.createDatabase.output.resourceId }}
- id: emitAudit
name: 写入审计结果
action: your-org:audit:record
if: ${{ always() }}
input:
requestId: ${{ parameters.requestId }}补偿不是机械地倒序删除。仓库已被提交业务代码、数据库已写入数据、DNS 已对外发布时,删除可能比残留更危险。每个动作应声明补偿类别:自动可逆、条件可逆、只能前向修复、需要人工确认。补偿时验证 external_id、管理标签和创建版本,绝不能按用户输入的名称盲删。删除失败时状态进入 NeedsReview,保留资源引用、失败码和下一动作;任务不能把“主流程失败”覆盖成笼统错误,导致值班人员找不到残留对象。
网络超时是最值得做的反向实验:让建仓 API 在服务端成功后、客户端收到响应前断开。错误处理不能立即再次 create,而应按幂等键查询账本与 Git Provider;若找到标签和目标摘要一致的仓库,记录外部 ID 后继续;若查不到,等待短退避再重试;若同名对象不匹配,停止并报警。证据包括只存在一个仓库、账本 attempt 增加、对账指标出现、审计事件保留原始 task 与重试 task 的关联。
版本化的不只是模板 YAML
模板版本是一个物料清单:模板 YAML 提交、骨架目录提交、每个 action 包版本、容器或运行时摘要、策略版本、生成器版本和依赖锁文件共同决定结果。只给模板标 v3,却让 action 与基础镜像浮动到 latest,同一次申请在不同日期会生成不同服务。稳定模板应引用不可变 tag 或 digest,并在任务记录中保存解析后的摘要。
破坏性变化包括删除必填参数、改变默认环境、重命名输出、修改资源命名、提高权限、改变清理语义和升级生成代码的运行时主版本。新旧版本应并行一段时间:已有服务继续知道自己来自哪个版本,新申请使用推荐版本,旧版本标记停止新建而不是立刻消失。模板升级先在隔离组织和沙箱云账号做正反实验,再以少量真实团队试用;回滚恢复模板与 action 版本,但不会自动撤销已创建资源,因此还需要资源级迁移或补偿计划。
供应链检查从模板仓库进入:CODEOWNERS 约束高风险目录,PR 中展示参数和步骤语义差异,依赖与 action 包生成 SBOM,构建产物签名,运行时只加载允许列表中的插件和 action。fetch:template 的远程 URL、npm 包、容器基础镜像和脚本下载地址都属于执行代码入口;应固定来源和摘要,限制网络出口,禁止模板通过任意 URL 拉取并执行未审查内容。
骨架本身也会泄露敏感信息。示例配置、测试夹具、生成日志和 .npmrc 可能携带内部域名、Token 或客户数据。dry-run 产物与失败任务附件按源代码制品保护,设置访问控制和保留期;平台日志对 authorization、token、secret 等字段做结构化脱敏,但不能依赖字符串替换兜底,action 从一开始就不应记录 secret 对象。
接入真实项目而不是制造空仓库
一个仓库创建成功,不代表 Golden Path 被采用。最小服务交付应连接目录实体、代码 Owner、构建入口、部署声明、文档位置、运行责任与成本标签。模板生成的 catalog-info.yaml 引用稳定 Owner;CI 配置调用组织复用工作流并固定版本;依赖更新、许可证和安全扫描有默认策略;README 只保存项目事实与本地启动入口,不复制会快速过期的平台说明。
项目接入先选择一个低风险服务类型,记录手工创建的真实耗时、失败点和后续补票。模板上线后比较从申请到首个可合并 PR、首个成功构建、目录可见、Owner 可解析和资源标签完整的时延,而不是只统计点击次数。若团队创建后立即删除模板生成内容,说明默认路径与真实工作不符;若大量任务在同一参数页退出,说明 Schema 或术语有问题;若成功率很高但残留资源增加,说明成功指标漏掉了补偿和对账。
CI 接入把模板仓库本身当产品测试。每次变更执行 schema 校验、无副作用 dry-run、生成物快照差异、secret 扫描、action 单测和沙箱端到端。正向用例证明标准服务能得到预期仓库树与目录实体;反向用例证明无权限 Owner、重复幂等键、配额不足、远端超时和补偿失败会进入可解释状态。沙箱对象使用统一前缀和短 TTL,测试结束按账本清理并确认存活对象数回到基线。
从现象回到状态与证据
“任务一直转圈”先查 task 状态、当前 action、租约、最后心跳和外部请求 ID。状态是 RUNNING 且心跳停止,可能是 worker 退出;仍有心跳但外部调用年龄增长,检查超时与 abort 支持;状态为 WAITING,按外部 ID 对账,不能直接重跑整条链。修复后验证任务进入明确终态,且同一幂等键没有第二份副作用。
“重试返回已存在”先比较对象标签、Owner 和 desired hash。完全一致且账本缺失,多半是成功响应丢失,应回填账本并继续;标签属于其他请求,判定冲突;对象部分一致,进入 reconcile 或人工 adopt,不能把 409 转成成功。再验证时同时查询 Git、云、目录和账本,四处引用应指向同一 request ID。
“补偿步骤没执行”先看失败步骤之后的 if 是否真的调用 failure() 或 always(),action ID 是否因连字符导致表达式解析异常,输出字段是否在失败前持久化。再看当前 action 是否吞掉错误并返回成功,或任务取消是否让 worker 直接终止。修复后注入同一故障,预期清理 action 有独立审计事件,资源集合回到基线;若清理失败,任务必须进入 NeedsReview 而非假成功。
“日志里出现 Token”立即撤销或轮换凭证,缩短任务日志保留,检查日志、缓存、artifact、错误追踪和聊天截图的复制范围。之后修改 action 的日志结构,只记录凭证来源、权限摘要和过期时间,不记录值;加入 canary secret 测试,故意传入标记值并扫描所有输出。仅把 UI 字段改成密码框不会清除后端泄露。
容量、成本和采用治理
模板 worker 的容量由到达率、平均步骤时长、外部限流和重试放大共同决定。长时间等待云创建的任务若一直占 worker,会拖死短任务;可把外部操作提交后持久化 operation ID,释放执行槽,由轮询或事件恢复。队列要按环境和风险分级,生产资源申请不能被大量开发仓库生成挤占。观察队列年龄、运行中任务、各 action 延迟、限流、重试次数、未知结果、补偿积压和残留资源,而不只看总成功率。
成本要归到 request、Owner 和模板版本。Git/CI 座席、runner 分钟、云数据库、Kubernetes 命名空间、制品存储、日志和 SaaS 自动化额度都可能由一次点击触发。dry-run 提示成本等级,执行前做预算与配额检查,创建后写成本标签,退役时从目录生命周期触发清理候选。生产阈值不能照抄固定数字,应由外部 API 配额、worker 基线、等待时延 SLO 和可接受残留预算推导。
采用治理由模板产品 Owner 与服务团队共同完成。平台团队维护动作、安全默认和运行可靠性,领域团队维护骨架中的业务约定,服务 Owner 对生成后实体和资源负责。每个模板要有支持渠道、弃用状态、变更记录、最近成功样本、补偿演练和替代版本。采用率应按适用服务群体计算,并结合生成后保留率、绕开率和手工修复量;强迫所有项目点击模板只会把低质量路径包装成高采用。
清理、回滚和长期退出
开发实验结束后,先停止新任务并等待或取消运行任务,再按副作用账本逆向检查目录登记、CI secret、云资源、仓库和临时凭证。删除前重新验证管理标签与 Owner;共享仓库或已写入业务数据的资源转人工确认。最后撤销实验 Git App 安装、云角色会话和 webhook secret,清理 dry-run 文件与任务日志,并确认外部对象数、运行任务数和补偿积压都回到稳定基线。
模板版本回滚只处理“未来新建使用什么”,已生成项目要按迁移清单处理。若新版本错误地开放了生产规格,先禁用该模板版本和高风险 action,再查询该版本摘要创建的所有请求,对仍在运行的任务取消并对账,对已成功资源评估保留或清理。审计记录保留原始参数摘要、授权决策、外部 ID 和处置结果,但 secret 与敏感业务参数按最小化原则删除或脱敏。
退出某个门户或模板引擎时,最重要的不是导出 YAML,而是导出可重放的事实:模板及 action 源码、版本物料清单、任务与副作用账本、外部对象标签、授权策略、审计记录和未完成补偿队列。先让新引擎以只读方式重建计划并与旧引擎结果比对,再切换新申请;旧引擎保留查询与补偿能力,直到所有运行和未知任务收敛。Golden Path 真正成熟的标志,是平台消失时团队仍知道每个对象为何存在、由谁负责以及怎样安全撤销。
