Aether Forgejo PAT 轮换 (aether-rotate-pat)
版本: 0.4.0 (GA) | Spec: #45 Phase 2 | 优先级: P1 AB Benchmark: 前基线 4/4 evals WITH_BETTER (2026-08-26, suite v0.2.2) —— WITHOUT 臂 在 eval-4/eval-5 critical FAIL。本版回归门数据见
references/changelog.md§ AB 基线锚点。
快速决策
PAT 即将过期或已过期?
├─ WARN cadence_33pct / cadence_10pct (aether doctor --check pat_age) → 走本 Skill 标准 Tier 1 流程
├─ FAIL critical (已逾期) / Tier 1 broken → 走 emergency runbook (web UI)
└─ 例行预防性轮换 → 走本 Skill
pat_age告警档自 cli-v1.17.0 (#302) 起按 class cadence 相对, 不再是绝对 14d/30d/7d (cadence: ci-build 30d / runtime 90d / ops·ops-rotation 180d)。分档公式与non_expiring的处理见 admission-forensics.md。
决策核心: 双 backend 全部 GA (2026-05-08 nomad-variables + 2026-05-09 forgejo-secrets).
nomad-variables(runtime class) — 4-step (cli-v1.20.0 起; 此前 5 步, 头一步DELETE) + atomic rollback + 24h grace, chaos-kill 可 resumeforgejo-secrets(ci-build class) — 2-step + best-effort rollback, chaos-kill 必须走 emergency runbook (单 slot 语义不能 auto-resume)
前置检查
aether version # 1. CLI 可用 + 版本 (需 >= 1.16.7)
aether status # 2. 集群可达
aether registry-auth list # 3. 当前 PAT 状态 (inventory + drift)
aether doctor --check pat_age # alert tier
aether doctor --check pat_inventory_drift # drift detection
doctor只接受--check <name>,不接位置参数 (Args: cobra.NoArgs)。写成aether doctor pat_age会得unknown command—— 该形态从未存在过 (Aether #275)。⚠️ 台账含第四/第五类 backend 的仓 (本仓自 #393 起) 需 cli ≥ 1.20.4。更早的 binary 内嵌 schema 不认
forgejo-push-mirror/github-actions-secrets,会把整份台账拒载 (INVENTORY_PARSE_FAILED, 列出 N 条「违例」并引导你去核对台账) —— 那是 CLI 太旧,不是台账写错。先升级,不要按报错改删条目。
⚠️
registry-auth list的 Consumers 列: 后缀就是它的可信度 (cli-v1.20.3 起)。nomad-variables印<已核验>/<登记>;forgejo-secrets印N (deferred);host-docker-config/forgejo-push-mirror/github-actions-secrets印N (declared)—— 后三个 backend 没有在线核验路径,(declared)明说这数字是登记而不是现场。⚠️ 但 cli ≤ 1.20.2 的机器上这两列是恒零的假零 (#340): 老版本算 total 只加
paths + repos,登记在hosts[]/mirrors[]的消费者一律印0—— 而0与「这把 token 没有任何消费者」完全同形。heavy-runner-pull-docker-config真值 5 台 host,而它恰恰又是台账自己记录被漏登记过两次的那一行 (heavy-4 / heavy-5)。 所以先aether version;是老版本就别拿这一列去核防漏清单,改走 JSON:aether registry-auth list --json | jq '.data.pats[] | select(.id=="heavy-runner-pull-docker-config") | .consumers.hosts | length'边界: 这是该列在老版本上的缺陷,别推广成「
list不可信」—— 同一条命令对 forgejo-secrets 行 会真的去查 Forgejo: 设了AETHER_FORGEJO_TOKEN就逐 repo 实查,没设才回落成deferred占位。看到deferred应先 export bootstrap PAT 再跑一次,而不是转去手工翻.forgejo/workflows。五列语义全表见 admission-forensics.md。
Tier 1 准入边界 (动手前先确认 entry 能自动轮换)
不是每个 inventory entry 都能走 Tier 1。--dry-run / --confirm / resume 共用同一准入点
(cli-v1.16.74 起 #281/#287 覆盖前两者,cli-v1.18.0 起 #334 把 resume 也接上),因此 dry-run
拒绝 = confirm 必然也拒绝,不必再试。准入拒绝共 9 种错误码 (cli-v1.18.0),各有确定的手工路径:
反过来不成立: 最后一道是问集群的,集群状态会变 —— dry-run 通过只说明当时准入已过, 不是一张长期通行证。中间被人改了 Variable 键名,
--confirm照样会在同一道墙上被拒。
| 错误码 | 触发条件 | 手工路径 |
|---|---|---|
VAR_KEY_UNSUPPORTED |
consumer 用自定义 var key (非 docker_auth_password),如 aria-build 的 FORGEJO_BOT_PAT |
aether env set --job <job> <KEY> --from-file <file> → 手工更新 inventory last_rotated |
ORG_LEVEL_UNSUPPORTED |
entry 是 org 级 Actions secret (Tier 1 只做 repo 级) | Forgejo org Settings → Actions → Secrets 手工换 → 手工写 last_rotated。见 runbook Mode 6 |
NO_ROTATABLE_CONSUMERS |
entry 有 consumers 块但 paths / repos 为空 → 轮换会"成功"却零次 API 调用 |
修 .aether/pat-inventory.yaml 的 consumers.paths / consumers.repos。见 runbook Mode 7 |
BACKEND_NOT_ROTATABLE |
backend 登记得没错,但 Tier 1 不碰它。三个成员: host-docker-config (凭据住在节点上的某个 docker config)、forgejo-push-mirror (GitHub PAT,住在 Forgejo 的 push-mirror 配置里,#384)、github-actions-secrets (第三方签发如 npmjs,存在 GitHub 仓的 Actions secret 里,#387) |
三支路径互不相通,别串:host-docker-config → 在报错列出的那些 host 上 docker login(--password-stdin);forgejo-push-mirror → 到 GitHub 上重新生成,再到每个 repo 的 Settings → Mirror Settings 重新填入;github-actions-secrets → 到发行方重新签发,再到 entry github_repos[] 列出的每个 GitHub 仓 Settings → Secrets and variables → Actions 更新 secret_name 同名 secret(只写不回读,换没换成只能靠下一次 CI 实跑验证)。三者事后都要手工写 last_rotated。见 runbook Mode 8 |
UNKNOWN_BACKEND |
consumers.type 不是五个合法值之一 (typo):nomad-variables / forgejo-secrets / host-docker-config / forgejo-push-mirror / github-actions-secrets |
修 .aether/pat-inventory.yaml。见 runbook Mode 8 |
FORGEJO_SECRET_NAME_INVALID |
forgejo-secrets entry 的 consumers.secret_name 缺失或不匹配 ^[A-Z][A-Z0-9_]*$ |
修 entry 字段后重跑。见 runbook Mode 9 |
FORGEJO_ORG_REQUIRED |
forgejo-secrets entry 缺 consumers.org (否则会打到 /repos//<repo>/...) |
补 consumers.org 后重跑。见 runbook Mode 9 |
PREFLIGHT_FAILED |
集群预检跑不起来(不是跑出了坏结果)。cli-v1.18.0 起 --dry-run / --confirm / resume 三者都可能返回 —— 预检已从 dry-run 专属移到共用准入点;覆盖两个探针:#312 逐 path 父 job 分类(仅 dry-run)与 #334 declared var_key 检查(rotate + resume) |
先读报错分两支:说 config 解不开 → 本地 .aether/config.yaml / ~/.aether/config.yaml 某字段类型不对(标量放在该放块的位置、列表放在该放字符串的位置),别去查集群;说探针打不通 → 修 NOMAD_ADDR / cluster.nomad_addr 可达性(aether doctor)。两支都零集群写入 |
DECLARED_VAR_KEY_ABSENT |
nomad-variables entry 的某条 declared path 上没有该 entry 的有效 var key(consumers.var_key 未声明时 = 默认 docker_auth_password)。不声明 var_key 也会触发 —— 默认槽位同样被逐 path 预检。一次列出全部缺键 path(非 fail-fast),exit 1,零集群写入 |
看报错里每条 path 的括号分两支:no Variable at this path = 该 path 上根本没有 Variable(先建,或它已不是 consumer 就从 entry 里删掉);Variable exists but holds no such key = 键名不同(把该 path 拆成自己的 entry 并写显式 var_key,之后它会以 VAR_KEY_UNSUPPORTED 被拒 → 走手工换)。改台账,不是加 flag。交叉核对 aether doctor --check pat_inventory_drift --json(kind=declared_var_key_absent) |
BACKEND_NOT_ROTATABLE与UNKNOWN_BACKEND意思相反,别混: 前者 = entry 是对的,Tier 1 是错的工具 (照上表手工路径走); 后者 = entry 是错的 (consumers.type拼错了,去修台账)。 把前者当后者处理,会让操作员去审一个本来就没问题的文件。⚠️ 对这个码 要读
error.message,别只读suggested_action:classifyBackendRefusal对三个成员 返回同一条suggested_action。cli ≤ 1.20.3 那条措辞是 docker-config 那一支的("docker loginon the hosts named in the message above")—— push-mirror / Actions-secret 照做等于跑去错误的机器; cli-v1.20.4 (#393) 起改为不指向任何 store 的中性文案。逐成员正确的做法只写在error.message里, 且它会指名这条 entry 自己的 host / mirror / repo + secret 名: 同一批节点上/root/.docker/config.json也存在且装着另一枚真凭据,照硬编码路径去docker login会成功地换错 store 然后 stamplast_rotated。push-mirror 与 Actions-secret 都注定进不了 Tier 1: Forgejo 的
GET push_mirrors从不返回凭据 (连remote_address的 userinfo 都被剥掉);GitHub Actions secret 只写不回读,且发行方 (npmjs) 与 存放处 (GitHub) 两个 API 工具都不说。既没有可指纹的旧值也无处写新值 —— 不是没实现,是 API 面上 就没有。字段全表见 admission-forensics.md。
判定顺序固定 (backend 归属 → var_key → 可轮换性 → forgejo 字段 → 集群预检),前四道只读台账文件、 最后一道才问集群。相邻结论(哪个码盖住哪个、
cleanup为何刻意不跑预检、残留 journal 挡不挡 rotate)见 admission-forensics.md。
--from-file 不是可选项: 手工路径一律用 --from-file。绝不把 token 值写成命令行
参数 —— argv 会进 shell history、ps aux、以及 AI 会话 transcript,与 stdout 是否遮蔽
无关 (#282)。
⚠️
last_rotated只在真的换过之后才写。工具拒绝执行时不要顺手 stamp —— 那会把一个 未轮换的凭据伪装成已轮换,比不轮换更危险。同理,改 inventory 字段 (例如去掉org_level) 把守卫绕过去,正是这些守卫要防的、更隐蔽的那种谎言。假 stamp 到底骗到了谁,见 admission-forensics.md。
轮换流程 (nomad-variables backend)
Step 1: 生成新 PAT (Forgejo web UI)
1. forgejo.10cg.pub → Settings → Applications → Generate New Token
2. Scope **精确匹配** inventory entry `scope` 字段 (不要给多余权限)
3. 保存到本地, chmod 600:
echo "<NEW_PAT_VALUE>" > /tmp/new.pat
chmod 600 /tmp/new.pat
Step 2: 计划核对 (--dry-run)
aether registry-auth rotate --pat-id <id> --dry-run
核对要点:
Consumer count= inventory 中的 paths 数量- 每个 path 都是预期 job 名
- 没有未声明的 consumer (drift 应先解决)
- 若报上节 9 码之一 → 该 entry 不走 Tier 1 (或环境/台账没就绪),转上节手工路径 (confirm 也会同样拒绝,别再试)
- cli-v1.17.0 起 plan entry 带
verify_class:job_not_found/batch_parent/dead类 path 只列 3 步 (跳 RESTART/VERIFY),信封有verify_skipped_count— 这是预期,不是缺陷; skip 清单的处置看 runbook Mode 8 四分类表
Step 3: 执行 rotation (--confirm)
aether registry-auth rotate --pat-id <id> \
--new-token-file /tmp/new.pat --confirm
每个 consumer 走 4 sub-step: PUT_NEW_OK → PUT_OLD_OK → RESTART_OK →
VERIFY_OK. journal 写入 .aether/tmp/rotation-state-<pat-id>.json (schema 1.2)。
PUT_NEW 是一次 compare-and-swap 写: 拿 pre-read 的 ModifyIndex 作 cas 条件, 并发
写会被 409 拒掉而不是静默覆盖。cli-v1.20.0 之前是 5 步, 头一步先 DELETE 再 PUT_NEW ——
那两步之间该 path 上什么都没有, 进程死在那里则兄弟键只剩内存里那一份 (见 Mode 6)。
schema 1.2 不向下兼容: 1.20.0 写的 journal, 老版本 CLI 读不了 (
ErrJournalSchemaMismatch), 反之亦然。resume 必须用启动那次轮换的同一个 CLI 版本。
期望输出: journal_status: complete.
Step 4: 验证轮换生效
aether status <jobname> 看 alloc 状态 —— 必要但不充分。
⚠️ Tier 1 的
VERIFY_OK只轮询 alloc 是否running,从不回读 token (#301)。 alloc 绿 = 调度成功,不等于新凭据可用。必须另外做一次真正消费新 token 的探测。
阳性对照 (真的用新 token 打 registry) — 内网端点,token 走配置文件不进 argv (#282):
umask 077
printf 'user = "%s:%s"\n' "<registry-user>" "$(cat /tmp/new.pat)" > /tmp/curlrc.$$
curl -sS -K /tmp/curlrc.$$ -o /dev/null -w '%{http_code}\n' \
"http://192.168.69.200:3000/v2/<org>/<image>/tags/list" # 期望 200
rm -f /tmp/curlrc.$$
200 = 新 token 真能读 registry;401 = 值没写对或 scope 不足。
(外网 forgejo.10cg.pub 前有 CF Access,裸 curl 得 302 而非 401,判不了凭据。)
❌ 不要用
ssh heavy-N 'docker pull ...'当验证 —— 它走节点/root/.docker/config.json,与本次轮换的 job 级docker_auth_password是 两套独立凭据;#234 prong b 之后节点那份已不是任何 Nomad job 的可用性依赖, 所以它无论轮换成没成都会绿 (典型假绿)。同理,靠 alloc 重启触发重拉也不可靠: 镜像本地已存在时 docker driver 可能根本不发 pull,auth 一次都没行使。
Step 5: 24 小时 grace + cleanup
等 24 小时 (让 mid-restart alloc 用旧 PAT 完成 pull). 然后:
aether registry-auth cleanup --pat-id <id>
最后 Forgejo web UI revoke 旧 PAT.
失败模式 + 恢复
Mode 1: Chaos kill mid-rotation
症状: --confirm 被 SSH 断连或 Ctrl-C 打断
恢复:
# 准备新+旧 token 文件 (同 rotation 时使用的两个 token)
echo "$NEW_PAT_VALUE" > /tmp/new.pat
echo "$OLD_PAT_VALUE" > /tmp/old.pat
chmod 600 /tmp/new.pat /tmp/old.pat
aether registry-auth resume --pat-id <id> \
--new-token-file /tmp/new.pat --old-token-file /tmp/old.pat
Resume 从 journal 续走 forward 或 rollback 路径; sub-step idempotent.
不要重新跑 --confirm —— 那会覆盖 journal、冲掉中断现场,已完成的 consumer 还会被再走一遍。
Mode 2: TOKEN_FINGERPRINT_MISMATCH
根因: 给的 --new-token-file / --old-token-file SHA256First16
不匹配 journal 记录 → 用错了 PAT.
修复: 必须用 原始 rotation 时 的 token 文件 resume. 旧 token 明文已不可恢复时 resume 无解 —— 走 emergency runbook 手工从 cluster 状态推断修复。
Mode 3: CLEANUP_REFUSED_INCOMPLETE
根因: journal status 不是 complete (可能 in_progress 或 rolling_back)
修复: 先 resume 推到 complete 再 cleanup. 或如果 rolled_back,
手动确认 cluster 状态正确, 删 journal, 重新 rotate.
Mode 4: BOOTSTRAP_NO_AUTOROTATE
根因: PAT class 是 ops-rotation (entry 根本没有 consumers 块; 自身不能 auto-rotate)
修复: Forgejo web UI 手动创建新 ops-rotation PAT → 更新 env → revoke 旧 token.
详见 emergency runbook. (与 NO_ROTATABLE_CONSUMERS 的「声明了却是空的」不是一回事。)
Mode 5: ROTATION_FAILED (rolled_back)
根因: 某 sub-step API 调用失败触发 atomic rollback
修复: 读 journal errors[] 字段定位根因 → 修复 (重启 Nomad / 扩
token scope / 排空节点) → 删 journal → 重新 rotate.
Mode 6: RESUME_PRE_STATE_LOST (cli-v1.18.0 新增)
先看 aether version: >= 1.20.0 轮换本身不会再产生这个状态 (主写改成一次 CAS PUT,
没有「path 上空无一物」的瞬间, #339) —— 仍报这个错 = 别的东西删了那条 Variable,
照下面走并去查是谁删的。1.18.0 ~ 1.19.x 则是轮换断在 DELETE 与 PUT_NEW 之间。
根因: journal 记的是该 consumer 的 Variable 已 DELETE,但那条 path 上现在没有任何
Variable —— 该 path 原本的其余兄弟键 (docker_auth_user 等) 此刻哪里都不存在
(*_OLD sibling 要到 PUT_OLD 才写,中断点还没走到)。完整链条见
admission-forensics.md。
修复: 不要再跑 resume —— 它会到达同一状态,唯一能做的就是把只含一个键的 Variable
写回去,那正是这道哨兵拦下来的事。手工用 aether env set --job <job> ... --from-file <file>
按该 path 应有的键集重建 Variable → 删 journal → 从干净状态重跑 rotate。
(该 path 原本有哪些键,schema 1.2 的 journal 在 current_consumer.pre_item_keys 里记着。)
这是止血哨兵,不是修好了: 在 cli-v1.18.0 ~ 1.19.x 上,该窗口只能响亮地停下, 不能恢复 —— 撞上了就只有上面那条手工重建的路。根治已随 cli-v1.20.0 落地 (#339): 主写变成单次 CAS 写,窗口不再存在。
forgejo-secrets backend 流程 (TASK-2.7b GA, 2026-05-09)
ci-build class PAT (例如 forgejo-actions-ci-2026-Q2) 走单 slot 2-step 流程, 与 nomad-variables 的 4-step 显著不同。
关键差异
| Aspect | nomad-variables | forgejo-secrets |
|---|---|---|
| Sub-steps | 4 (cli-v1.20.0 前 5) | 2 (DELETE + PUT_NEW) |
*_OLD sibling |
有 (24h grace) | 无 |
| Atomic rollback | 完整 | best-effort in-process |
| OldToken 来源 | cluster 自动读 | 必须 --old-token-file (Forgejo Actions 写-only API) |
| Chaos-kill resume | 支持 | 拒绝 → emergency runbook |
| Bootstrap 凭据 | cluster.NomadToken |
AETHER_FORGEJO_TOKEN env (write:repository) |
⚠️ 两个 backend 的 bootstrap scope 不同:
nomad-variables走/users/<n>/tokens需write:user;forgejo-secrets走/repos/<o>/<r>/actions/secrets需write:repository。实战推荐 bootstrap PAT 两个都勾,一次创建覆盖两个 backend。 权威 access matrix 见 runbook §Pre-requisites。
用法
# ENV (按需)
export AETHER_FORGEJO_ADDR="http://192.168.69.200:3000" # 默认值
export AETHER_FORGEJO_TOKEN="<bootstrap PAT, write:repository>" # 必需 (勿用 write:user-only)
chmod 600 /tmp/new.pat /tmp/old.pat # 注意: 必须双 token 文件
# 同一条命令换末尾 flag: --dry-run 先预览 (N repos x 2 sub-steps), 再 --confirm 真执行
aether registry-auth rotate --pat-id forgejo-actions-ci-2026-Q2 \
--new-token-file /tmp/new.pat \
--old-token-file /tmp/old.pat \
--dry-run
# 完成后立即可 cleanup (无 24h grace, 无 _OLD sibling)
aether registry-auth cleanup --pat-id forgejo-actions-ci-2026-Q2
# Forgejo web UI revoke 旧 PAT, 删本地文件
rm /tmp/new.pat /tmp/old.pat
中断恢复 (chaos kill / SSH 断)
aether registry-auth resume --pat-id <id> --new-token-file /tmp/new.pat --old-token-file /tmp/old.pat
# → exit 1, error.code = FORGEJO_MANUAL_RECOVERY_REQUIRED
forgejo backend 设计上拒绝 auto-resume (单 slot 无 _OLD 备份, OldToken 不持久化, 无 fingerprint guard). 必须走 emergency runbook 手动逐 repo 确认 secret 状态后修复. 详见 docs/guides/forgejo-pat-emergency-rotation.md。
失败模式 (forgejo-specific)
- MISSING_OLD_TOKEN_FILE: 没传
--old-token-file→ 必须给 (单 slot 无法 cluster 自读) - MISSING_FORGEJO_TOKEN:
AETHER_FORGEJO_TOKENenv 未设 → 设为 bootstrap PAT - FORGEJO_MANUAL_RECOVERY_REQUIRED: chaos-kill 后 resume → 走 emergency runbook
- CLEANUP_REFUSED_INCOMPLETE: journal status 非 complete → resume 推到 complete (会触发 emergency 路径)
⚠️
secret_name是 entry 级, 全部 repo 共用一个(forgejo_rotate.go取一次Consumers.SecretName后在 repo 循环里反复用)。所以一个 entry 装不下「不同 repo 用不同 secret 名」—— 那种情况要拆成多个 entry, 不是在一个 entry 里想办法。⚠️
rolled_back≠ 已验证恢复。best-effort rollback 的实际动作是: 按CompletedConsumers逆序逐 repo「DELETE 新值 → PUT 回旧值」, 再对失败点那个 in-flight repo 补一次 PUT 旧值。但这些调用的错误是被直接丢弃的(源码里是_ =),既不中断也不上报。 所以 journal 写rolled_back只说明回滚流程跑完了, 不说明每个 repo 的旧值真的回去了 —— 必须自己逐 repo 核一遍 secret 的created_at(list那条实查路径, 或 Forgejo UI)。
操作员 checklist
□ aether registry-auth list — 确认 inventory + drift
□ aether doctor --check pat_age — 确认 alert tier
□ Forgejo web UI 生成新 PAT (匹配 scope)
□ chmod 600 /tmp/new.pat
□ aether registry-auth rotate --pat-id <id> --dry-run
□ 核对 plan (若报准入拒绝 9 码之一 → 转手工路径, 不要 --confirm)
□ aether registry-auth rotate --pat-id <id> --new-token-file /tmp/new.pat --confirm
□ aether status <jobname> — 验证 alloc running
□ registry /v2 tags 探针 (新 token, -K 配置文件) — 期望 200; **不要**用 ssh docker pull (另一套凭据, 恒绿)
□ pat-inventory.yaml last_rotated 更新 + git commit
□ 等 24 小时
□ aether registry-auth cleanup --pat-id <id>
□ Forgejo web UI revoke 旧 PAT
□ rm /tmp/new.pat
参考资源
- 本 skill 深度层 (自包含): admission-forensics.md
(判定顺序 · cleanup/journal 语义 ·
last_rotated被骗的是谁 ·pat_age分档 · 四 backend 列语义) · changelog.md (历次追平内容 + AB 基线锚点) - Canonical runbook: docs/guides/forgejo-pat-rotation.md
- Emergency fallback: docs/guides/forgejo-pat-emergency-rotation.md
- Spec: openspec/changes/forgejo-pat-rotation-mechanism/ · Issue: Aether #45
Last updated: 2026-09-09 (Aether #390 追平 cli-v1.20.3 · Aether #357 深度层移入
references/)。逐版内容见 changelog.md。