# ADR-0140:有界 Plugin Package 启动恢复与准入门禁 - 状态:Accepted(Profile-neutral 恢复协调器、本机 production application gate、 Cluster 一次性 admin process、标准 OCI resolver、独立镜像与最小权限 Job/RBAC 已实现;真实 Kubernetes API、最小 RBAC、双 Pod ConfigMap CAS/response-loss 专项门,以及 PostgreSQL+HTTPS OCI+Kubernetes recovery Job 与 deployment-controller wait 组合门已通过;私有 registry credential provider 已保持在一次性 admin Job 内实现) - 日期:2026-07-25 - 关联 RFC:QL-RFC-0001 D-08、D-09、D-132 至 D-138 - 关联 ADR:ADR-0036、ADR-0087、ADR-0134、ADR-0136 至 ADR-0139 > 权限模型更新:本文记录的 `ql3_admin` recovery authority 已由 ADR-0144 和 > `pg-0022-plugin-package-authority-split` 取代。当前 recovery 只使用独立 > `ql3_package_executor` LOGIN、executor readiness 与 > `QL3_POSTGRES_PACKAGE_EXECUTOR_*` credential;本文其余恢复状态机与部署顺序决策 > 保持有效。 ## 背景 ADR-0134 至 ADR-0139 已经提供: - 原子保存完整 PackageLock 的 SQLite/PostgreSQL Repository; - `queued → staged → activating → active|failed` 的 CAS 状态机; - 本地 POSIX 和 Cluster ConfigMap active pointer publisher; - fresh activation 的 publish 与 `activating` 恢复的 inspect 分离。 但 Repository 只暴露 recoverable current-head page,尚没有一个统一 startup coordinator 把页面安全收敛到准入条件。直接复用 `PluginPackageInstallationCoordinator.install()` 不可接受,因为它会再次消费一次性 Approved Action;简单地对所有记录调用 activation coordinator 也会让并发 loser 在 winner 尚未完成 publish 时执行 inspect,把暂时 absent 错判为 durable failure。 恢复还必须同时适应两种设备: - 路由设备不能为插件安装常驻 timer、watcher、额外连接或并行 I/O worker; - Cluster 多副本不能依赖单进程锁,必须允许 CAS loser 保留可恢复工作并在后续 pass 收敛。 ## 决策 ### 1. 恢复是调用方驱动的有界 cycle `@qinglong/runtime-core/plugin-package-recovery` 提供 `PluginPackageRecoveryCoordinator`。它不创建 timer、watcher、socket、数据库连接、 后台任务或并行 worker,只使用注入的: - `PluginPackageInstallRepository`; - `PluginPackageStageProvider`; - `PluginPackageActivationPublisher`; - 权威 `now()`。 Repository page 保持每页最多 64 条。单次 cycle 最多 64 页,默认 `pageSize=16`、`maxPages=16`;页内严格串行,调用者根据结果决定何时运行下一轮。 这样低配设备只承担一个 current head 的瞬时内存和 I/O,Cluster 则继续依赖数据库与 publisher CAS,而不是把并发转移到 coordinator。 ### 2. queued 恢复不得重新消费审批 durable `queued` 证明 create 前的 Approved Action 已经消费并绑定到完整 PackageLock。 恢复路径必须: 1. 按 record 的 lock digest 读取耐久 PackageLock; 2. 重新复验 lock 与 installation 的 Project、Package、generation 和 digest; 3. 调用幂等 stage provider; 4. 以 domain-separated mutation ID 提交 exact `staged` CAS。 它不得调用 `install()`,也不接受 Approved Action consumer。审批不存在、过期或外部 系统暂时不可用都不能改变已耐久 create 的事实。 ### 3. 状态所有权决定 activation 动作 - 初始 `staged`:可以调用 fresh `activate()`,由 staged→activating CAS 决定 publisher authority; - 初始 `activating`:只能调用 `inspect()`,永远不能 republish; - 初始 `queued`:只有本次 commit 返回与本节点计算完全相同的 staged record,才可 继续竞争 fresh activation。 若 queued commit 返回已经被其他恢复者推进的 `activating` 或其他 recoverable record, 本节点返回 `retry`,不能立即 inspect 或 publish。winner 可能仍在外部发布窗口内; 后续调用方驱动 pass 才能把它作为“初始 activating”检查。 即使 staged CAS 之后另一副本先推进,activation coordinator 也只会产生 CAS conflict; 当前 pass 重读 durable head并保留 retry,而不会把 loser 升级为外部 mutation owner。 ### 4. 冲突与不可用必须重读 durable head 每条结果只公开四种低敏状态: - `settled`:同 installation 已 durable `active|failed`; - `retry`:同 installation 已前进但仍 recoverable,或 authority 暂时不可用; - `manual_required`:durable shape/evidence、mutation identity 或未变化的 CAS 事实 冲突; - `superseded`:current head 已不存在或由另一 installation 替代。 transition conflict 与 unavailable 都必须重新读取并规范化 current head: - terminal → settled; - 同 installation 的不同 recoverable digest → retry; - 不同 installation/不存在 → superseded; - 未变化却持续 transition conflict → manual-required。 未知基础设施错误不泄露诊断,当前 pass 返回 retry;产品层以低敏计数、审计和告警承接 具体运维证据。 ### 5. Recovery page 是不受信任的 durable projection coordinator 在处理前逐项复验: - page 只有 `records`、`truncated` 和可选 `next`; - records 数量不超过请求 limit; - 记录均为 current recoverable state; - `packageName, installationId` 严格递增且无重复; - `truncated` 与 `next` 同时存在或同时不存在; - continuation 必须精确等于本页最后一条 identity。 malformed、terminal、乱序或 detached cursor page 直接失败关闭,不做部分页面 mutation。 ### 6. Cursor 耗尽后仍需从头探测 恢复过程中,另一个副本可能插入一个排序在当前 cursor 之前的新 head。因而“最后一页 在 cursor 之后为空”不证明全局没有可恢复工作。 每个 cycle 结束必须从头执行 `limit=1` final probe。只有同时满足以下条件才能返回 `safeToAdmit=true`: 1. 未因 `maxPages` 截断; 2. final probe 不含任何 recoverable head; 3. 本轮没有 `manual_required`。 product startup composition 必须在 lifecycle 和 admission 前调用该 cycle,并对 retry、 manual、remaining、page corruption 或 coordinator unavailable 失败关闭。 ### 7. 保持现有 package 粒度 协调器只有 Profile-neutral 状态机职责,因此并入既有 runtime-core 显式 subpath; SQLite、POSIX 和 Kubernetes integration 只放在各自现有 package 测试中。不新增 workspace package、第三方依赖或默认 root export。 这延续 ADR-0087:单文件能力不因“看起来独立”就拆包,只有独立发布、独立进程或真正 不同依赖生命周期才形成新 package。 ### 8. Production composition 按 authority 分成 Local gate 与 Cluster admin Job 本机 `@qinglong/local-application` 在 adopted storage ready 后,从同一个 `LocalSqliteRuntimeDatabase` 取得 Plugin Package Repository,不新建 SQLite 连接。 该 Repository 通过惰性异步 port 在首次恢复时构造;只启动 edge/standalone storage 不会加载 Plugin Package adapter。 enabled 配置必须显式提供 stage provider、预创建私有 staging/activation root、权威 时钟及可选页预算。顺序固定为: ```text adopted storage ready -> bounded Plugin Package recovery safe -> Secret keyring ready -> Run/receipt/domain recovery -> lifecycles -> admission ``` queued lock 没有可用的 content-addressed source resolver 时,stage provider unavailable 只能得到 `retry/remaining`,application 必须关闭 storage、释放 adoption fence 并拒绝启动;不得跳过该 Package 或假装空队列。disabled 路径不检查这些目录或 provider。 Cluster 不得把恢复接入常驻 `cluster-control`。Plugin Package 三表和 Project lock function 是 `ql3_admin` 专属 authority,runtime role 明确没有权限。独立 `@qinglong/cluster-admin/plugin-package-recovery` 一次性 composition 负责: 1. 打开 admin Pool并执行完整 admin schema/role readiness; 2. 组合 PostgreSQL Repository、外部 stage provider/evidence verifier 与 Kubernetes ConfigMap publisher; 3. 串行执行一个 bounded recovery cycle并要求 `safeToAdmit=true`; 4. 无论成功、readiness 失败、恢复失败或 close 失败,都在返回前关闭 Pool。 该 subpath 不从 cluster-admin root 导出,也不进入 cluster-control 默认依赖闭包。 production Kustomize/Helm 必须把它做成短生命周期、失败阻断后续 rollout 的 admin Job。`ql3-plugin-package-recover` binary 已组合: - 只使用 `ql3_admin` 连接、单连接 Pool 与完整 admin readiness; - 独立 `qinglong3-cluster-admin` 镜像,不把 Kubernetes client 或 admin package 加入常驻 `cluster-control`; - 独立 ServiceAccount,以及 namespace 内 ConfigMap `get/create/update` 的单一 Role;无 Secret、list/watch/delete 或 ClusterRole; - 固定 600 秒 deadline、零 Job retry、非 root、只读 rootfs、RuntimeDefault seccomp 和显式资源上限; - migration → Plugin Package recovery → runtime rollout 三段式部署顺序。Kustomize 只负责渲染,deployment controller 必须等待前一 Job 成功,不能把两个 Job 和 Deployment 同时 apply 后宣称形成门禁。 OCI resolver 将 `locator` 中的 digest 正确定义为 OCI manifest digest,将 `artifactDigest` 定义为唯一 QingLong bundle layer digest;两者独立绑定同一 PackageLock,不再错误要求相等。resolver 只访问显式 allowlist 的 HTTPS registry, 拒绝 redirect 与 ambient credential,验证 content-addressed manifest/config、唯一 bundle descriptor、lock-annotated OCI signature referrer、Ed25519 trust 与流式 bundle 内容。证据摘要以 `lock.createdAtMs` 作为确定性签名观察时点,重启后可从完整 lock 重新取证;进程内最多缓存 64 条低敏 evidence,不缓存 bundle。 私有 Registry 不触发 Docker config、credential helper 或 challenge-based ambient discovery。可选 `QL3_PLUGIN_PACKAGE_REGISTRY_CREDENTIAL_FILE` 只在一次性 `cluster-admin` recovery process 中加载 `qinglong/plugin-package-registry-credentials@v1`: - 文件必须是绝对路径、最多 256 KiB、regular file,允许 owner/group read,但拒绝 group write/execute 与任何 world 权限; - 最多 32 个 credential,每项必须精确匹配 OCI allowlist 中的一个 registry,重复、 多余 registry 和未知字段失败关闭; - 只接受显式 Basic username/password 或 Bearer token;provider 只有 `authorizationFor(exactRegistry)` 窄能力; - source 通过 allowlist 后才查询 provider;Authorization 只随同一 HTTPS registry 请求发送,redirect 继续为 `error`; - process 在成功、配置/网络/Kubernetes/数据库失败时都 dispose provider,并清空其 持有的 Authorization Buffer。JavaScript 临时 string 不提供可证明的零化,因此 credential authority 仍依赖短生命周期 Job,而不能进入常驻 control。 默认未配置该文件时行为保持公开 Registry、无 Authorization header。生产 overlay 通过单独 `private-registry` Kustomize 层把现有 CloudNativePG recovery Job 绑定到 一个 `0440` Secret 文件;默认 base 不创建、不引用 Registry Secret,recovery ServiceAccount 也没有 Secret API 读取权限。 完整组合门已让真实 PostgreSQL 18 migration、durable queued installation、经 TLS 的 content-addressed OCI Distribution GET/referrers surface、真实 recovery Job、ConfigMap publisher 与双副本 runtime rollout 在同一个三节点 Kind 中依次完成;deployment controller 只有在读取到 recovery Job 的 `Complete=True` 后才创建带该 Job UID/resourceVersion/completion time 绑定的 Deployment。该 OCI fixture 不是生产 Registry 存储/认证实现,测试 PostgreSQL 也显式关闭 TLS;对象存储来源、production TLS 与 Approved Action 产品 consumer 仍须独立完成,因此 Cluster 插件安装产品入口仍不能视为 完整开放。 ## 拒绝的方案 - 复用 `PluginPackageInstallationCoordinator.install()`:拒绝;会重新消费一次性 Approved Action。 - 对 queued/staged/activating 统一调用 publish:拒绝;响应丢失会重复外部副作用。 - 并行处理整页:拒绝;放大路由设备 I/O,也缩短不了数据库或 Kubernetes 的权威 CAS。 - CAS loser 立即 inspect winner:拒绝;winner 可能仍处于 publish 窗口,absent 不是 durable failure。 - 只在当前 cursor 后探测:拒绝;会漏掉并发插入到 cursor 之前的 recoverable head。 - coordinator 自建重试 timer:拒绝;会形成 Profile 隐式常驻资源、停机竞态和每副本 重试风暴。 ## 影响 - edge/standalone 获得单记录、串行、caller-driven 的恢复边界;禁用或未组合时没有 常驻开销。 - Cluster 多副本继续以 PostgreSQL record CAS 与 Kubernetes resourceVersion CAS 协调,coordinator 不引入第二套 leader election。 - 常驻 cluster-control 不获得 Plugin Package 表或 Project lock function 权限;恢复 只由短生命周期 admin authority执行。 - 恢复不再依赖重新取得 Approval 服务,也不会把一次性审批降级为可重复凭据。 - workspace importer 经 ADR-0217 保持 20;没有新增 workspace package。ADR-0218 又从 admin 移除了不使用的 Croner,既有 `@kubernetes/client-node` 只进入独立 admin 镜像。 - admin 镜像使用独立 builder/runtime 双 lock:4 个 production root、84 个 external package,加 3 个 QL3 workspace package;常驻 control 镜像闭包不变。 - ADR-0128 的 SBOM/OCI/release verifier 以 exact `control|admin` profile 复用: admin 当前 lock/SBOM 为 87-component CycloneDX/88 dependency node;更新前实际 镜像曾在非 root、只读根下完整对账,更新后的真实 inventory 待依赖物化复验。CI 与 release workflow 分别为两个 image repository 生成独立 amd64/arm64 manifest、digest、签名与证明,control 证据不能替代 admin。 - production wiring 后六种本机制品仍为 334/368/439 files。惰性 Repository port 使 edge/standalone 保持 39 loaded modules、adopted 保持 42;只有 application 入口加载 78。最大 application 为 2,849,582 bytes/439 files,最大 RSS delta 13,221,888 bytes,仍低于 4 MiB/512 files/16 MiB 门禁。 ## 验证 当前门禁覆盖: 1. queued→stage→activating→active,且 coordinator 没有 Approved Action 端口; 2. 初始 activating 只 inspect,不 publish; 3. publish response loss 首轮保持 activating,下一轮 inspect 收敛; 4. queued stage commit 被并发恢复者推进后返回 retry,publish/inspect 均为 0; 5. stage unavailable 保持 queued、阻止 admission; 6. invalid stage evidence 标记 manual-required; 7. multi-page 扫描与 final head probe 捕获 cursor 之前的新 work; 8. malformed order/continuation page 在 mutation 前失败; 9. 真实 SQLite Repository + POSIX publisher 从 durable queued 收敛 active; 10. Kubernetes ConfigMap create response loss 经 durable activating 在下一轮收敛,且 create 调用保持 1; 11. local-application 14/14,包含空队列顺序与 queued source unavailable fail-closed/释放 fence; 12. cluster-admin 61 pass/1 条件 skip,包含标准 OCI manifest/layer digest 分离、 registry allowlist、redirect-free streaming、referrer signature、重启重新取证、 exact-registry Basic/Bearer provider、私有文件权限/绑定/清理、process 配置、 一次性 admin composition、返回前 close 与 root export isolation; 13. 21-package clean build/聚合测试、dependency/source boundary 与 edge import 通过,`findings=[]`; 14. 六种 Profile artifact 全部低于资源硬门; 15. PostgreSQL 18.4 arm64 HA Docker 门 21 个具体 gate 与总 `passed` 全为 true: fail-closed 262.122 ms、双 fresh activation 457.148 ms、`pg_rewind` 1,992.910 ms,且 `unexpectedDomainSideEffects=0`、门后无遗留 HA 容器/volume; 16. admin 当前 SBOM 为 84 external + 3 internal、88 dependency node, 双镜像 SBOM/OCI/release 38 项负向合同及 11 项部署合同全部通过; 17. 独立 Kind 1.32.8 门使用两个真实 projected ServiceAccount token 的 `cluster-admin` Pod;两者以同一个 `resourceVersion=487` 竞争,得到一成功、 一 conflict,最终只有一个 active pointer。API 已确认 create 后的客户端边界 response-loss 经 inspect/exact replay 收敛,create 保持一次;ConfigMap get/create/update 允许,list/delete、Secret 读写及跨 namespace GET 全部 403。 18. 三节点 Kind 组合门从当前源码构建 admin/control 镜像,运行 PostgreSQL 18.4 的 18 条 reviewed migration/capability v17,使用正式 PostgreSQL Repository 持久化 一个 durable queued installation,再由真实 `ql3-plugin-package-recover` Job 通过 exact-registry Basic credential 完成 6 个唯一 authenticated HTTPS OCI manifest/config/referrer/signature/bundle 请求、 `queued→staged→activating→active` 四次 mutation 与唯一 ConfigMap pointer。 recovery SA 仍只有 ConfigMap get/create/update,runtime role 读取 Plugin Package 三表被 PostgreSQL 拒绝;deployment controller 观察 recovery Job 完成后才创建 绑定其 UID/resourceVersion/completion time 的两副本 Deployment,两 Pod 分布在 不同 worker。arm64 现场总耗时 152,189 ms,完成后精确删除临时集群。 PostgreSQL HA 门验证数据库 migration/repository 与既有领域事务的 promotion/rewind 基础链路;双 Pod Kind 门独立验证 Kubernetes ConfigMap publisher 与 RBAC;三节点 组合门已经把 PostgreSQL、HTTPS OCI、正式 recovery Job 与 deployment-controller rollout wait 合成。response loss 仍只在 API 确认 create 后的客户端边界注入,不是 raw-wire packet loss;组合门的 PostgreSQL 显式关闭 TLS、OCI endpoint 是只读 content-addressed fixture,单 control-plane Kind 也不是 Kubernetes control-plane HA 证明。生产 Registry 存储/认证、production PostgreSQL TLS、admin Buildx 真实双架构 OCI 记录和受保护 GHCR/Cosign/GitHub attestation 记录仍是独立 Gate。