From 1ff5aed844480b00a34b3714609538ff234b8bde Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 18 Aug 2026 06:54:25 +0800 Subject: [PATCH] feat(ql3): observe boot runs without false origins --- .../compatibility/legacyExecutionBridge.ts | 3 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 14 +- ...-crontab-compatibility-and-shadow-write.md | 29 +- ...7-boot-shadow-and-non-origin-boundaries.md | 67 +++++ docs/adr/README.md | 1 + test/back/legacyBootShadow.test.cjs | 274 ++++++++++++++++++ .../back/legacyScheduleServiceShadow.test.cjs | 14 +- 7 files changed, 382 insertions(+), 20 deletions(-) create mode 100644 docs/adr/ADR-0447-boot-shadow-and-non-origin-boundaries.md create mode 100644 test/back/legacyBootShadow.test.cjs diff --git a/back/runtime/compatibility/legacyExecutionBridge.ts b/back/runtime/compatibility/legacyExecutionBridge.ts index efde7df2..1f929464 100644 --- a/back/runtime/compatibility/legacyExecutionBridge.ts +++ b/back/runtime/compatibility/legacyExecutionBridge.ts @@ -20,6 +20,7 @@ import { LegacyExecutionRegistry } from './legacyExecutionRegistry'; const SHADOW_ORIGINS_ENV = 'QL3_SHADOW_ORIGINS'; const SUPPORTED_SHADOW_ORIGINS = new Set([ + 'boot', 'manual', 'scheduled_node', 'scheduled_system', @@ -85,7 +86,7 @@ function readConfiguredOrigins(): ReadonlySet { incrementFailure('configuration:unsupported_origin'); try { Logger.warn( - '[ql3-shadow] ignored unsupported origin; this slice supports manual,scheduled_node,scheduled_system,script,subscription,system', + '[ql3-shadow] ignored unsupported origin; this slice supports boot,manual,scheduled_node,scheduled_system,script,subscription,system', ); } catch { // Invalid compatibility configuration must not affect legacy paths. diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 4ccb6796..078d5231 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,18 @@ 最新增量证据(2026-08-18): +- D-355/ADR-0447(已接受):完成 `once/boot/grpc` 的独立裁决,但不因枚举存在而虚构三条 owner 边界。`bootTask` 是唯一真实独立触发路径:只筛选 + enabled `@boot`,固定以 `boot` origin 复用 `runSingle` 已创建的同一个 ChildProcess;显式 Shadow 时形成 legacy-owned terminal + Run/Attempt/八 Event,默认关闭且失败开放。`@once` 当前只是被 system/node scheduler 排除的 schedule 标记,用户执行仍进入 manual;gRPC + `runCrons` 只是委托同一 `CronService.run` 的 transport,也保持 manual。`once/grpc` 保留在 domain/schema vocabulary,但不进入 Legacy Shadow + allowlist,避免按任务定义或传输协议污染 origin、完整率和 Primary 门禁。本切片不新增 package、生产依赖、schema、migration、timer、watcher、进程、 + 端口或部署对象;路由设备默认零 Repository/listener/写入,cluster 也不获得新的本机 owner。阶段门已重跑:Legacy Shadow 聚焦 + 42/42、`build:back`、完整 backend 1,419 pass + 2 条条件 skip/0 fail、18-package clean build/test、14/14 静态审计与 14/14 + artifact 档位全部通过;artifact 字节与 D-354 一致:基础 Edge/Standalone `2589998/2590076`、adopted + `2809293/2809416`、application `3632877/3632997`、application-api `3800430/3800574`、AI + `3069251/3069341`、application+AI `4493151/4493283`、MCP `7315930/7316038`。本阶段不修改数据库 + schema/adapter、容器或 Kubernetes 拓扑,因此没有重跑物理 PostgreSQL HA/K3s 门,也不把相邻阶段结果冒充新证据。 + - D-354/ADR-0446(已接受):关闭 system crond Shadow 的最后一个准入身份缺口。只有实际 system scheduler 写出的 crontab 命令带 `QL_EXECUTION_ORIGIN=scheduled_system`;Node scheduler、manual/boot 与直接 Shell 调用不带标记,避免一次执行双记。Shell 每次 被标记执行只生成一个 `legacy-system::`,start/finish callback 原样复用;Linux 路由设备优先读取 kernel UUID, @@ -9070,7 +9082,7 @@ flowchart LR | PR-1 Run Schema | Incubating | Run/RunAttempt/RunEvent schema、nullable cancel request 与 Attempt deadline 字段及恢复索引、CancellationDispatch 状态/version/lease/backoff schema、Repository port、临时 Sequelize adapter、统一事件大小/分页上限、跨 adapter RunRepository contract suite(原子事务、回滚、Run/Attempt/RetryPolicy CAS、唯一错误、分页与取消恢复);ADR-0041 的 `pg-0003-run-retry-policy`、capability v2、driver-neutral PostgreSQL Run Repository 与真实 `pg.Pool` 上的共享 Repository/rollback/SQLSTATE contract;ADR-0063/0069/0071/0073/0074/0076 的独立 Node 24 local-sqlite typed schema、十二条 reviewed migration、capability v6、共享 operation authority、readiness/RunRepository/API credential repository/receipt journal/dispatch plan/encrypted Secret envelope/Project Policy/security audit/authorized mutation/stable Identity catalog、Drizzle↔真实 catalog table/column/index/CHECK/FK lockstep、base/adopted/application edge/standalone 产物门禁;ADR-0064 的 legacy baseline/plan digest、Online Backup recovery、side-by-side target migration、staged manifest、双库栅栏 activation、source 生命周期写栅栏、target stable identity 和重启语义;ADR-0065 的独立 cutover authority、外部副作用停机 evidence、append-only journal、start/restart/stop barrier 与 unknown→manual_required 收敛;ADR-0066 的 adopted storage→Run reconciliation→receipt maintenance→domain recovery→lifecycle→admission application gate、严格有界 recovery summary 与 admission-first reverse stop;ADR-0067 的 SQLite 事实驱动 Run 候选源、256 条硬上限、截断失败关闭和唯一 Repository authority;ADR-0068 的 receipt-first Reconciler、callback token/sequence fence、exact local-process identity、Attempt/Run/双 Event 原子终态推进和最终 verifier;ADR-0069 的 local-process 单向包边界、pre-spawn journal、受审 POSIX launcher、immutable receipt、exact identity 和 Profile-aware cleanup lifecycle;ADR-0070 的独立 local-execution、spawn 前后双 transaction CAS、callback digest、exact stop 补偿与 fail-closed starting 保留;ADR-0071 的独立 local-dispatch、不可变 revision/context、Secret-first materializer、Profile Artifact admission、4/64 MiB output hard quota 和窄 application facade;ADR-0073/0074 的 Project-bound SecretRef、AES-256-GCM、外置 keyring 生命周期、双 SQLite authority CAS、application preflight、强 Principal/Policy 和 envelope+audit 原子提交;ADR-0086 的本机 Owner provisioning/challenge/claim/delivery acknowledgement/credential recovery CLI;ADR-0377 的 Local/Cluster 同构、Profile-aware、Project-scoped Artifact range read | fresh database/pepper setup、credential rotation/GC 运维编排与 Secret/Project/Role/Approval 管理 CLI/API/UI、备份/rekey、2.x/target process controller、人工 recovery、target 写后 reconciliation 与完整 cutover/rollback 演练;retry 产品策略、Artifact retention/tombstone stack、具体本机 lifecycle 和 target executable;Linux x64/arm64、PID namespace、断电与固定路由设备门禁;PostgreSQL 16/18 双连接并发与 failover integration;Task revision/context 跨方言 contract/并发压力与引用感知 retention、Keyv 数据迁移 | | PR-2 Run 状态机 | Incubating | 纯转换表、终态/时间/错误/执行器元数据规则、Run version 与 event sequence CAS、事务性 RunCommandService、回滚测试 | 重复 Worker callback/fencing、并发数据库压力测试、Primary 执行链接入 | | PR-3 Executor 端口 | Incubating | ADR-0003、ExecutionSpec/Context/Handle/Result、Executor port、LocalProcessExecutor、进程组取消/超时升级、流式背压、Legacy Cron spec builder、真实进程 contract tests、可复现 edge 基准入口 | 固定 edge/多架构设备基线、Legacy builder 与 makeCommand 差异审计、Primary 生产流量接入 | -| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;失败开放和契约测试 | once/boot/grpc 独立裁决;启动后 Reconciler、差异报表、可采集指标、资源压力、回滚演练和 Primary 门禁 | +| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、boot、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;`@once` 保持 manual、gRPC transport 不冒充 origin 的准入裁决;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;失败开放和契约测试 | 启动后 Reconciler、差异报表、可采集指标、资源压力、回滚演练和 Primary 门禁 | | PR-5 Primary LocalExecutor | Incubating(默认不激活,仅 manifest-gated manual) | runtime-owned Run 创建器;持久化先于 spawn;Run/Attempt 完整成功、失败、取消、超时与 lost 闭环;Executor handle 身份校验;spawn 后激活写失败的 stop+lost 补偿;completion rejection 安全收敛;独立 Primary 幂等查询与唯一索引竞态裁决;durable `run.cancel_requested`、stop-before-signal、首次请求幂等、晚到完成裁决与待取消有界恢复查询;最多 64 条一页的 cross-worker cancellation source;独立 CancellationDispatch Repository 原子 claim/result、lease expiry 接管、owner/token/version fencing、指数退避与结果 RunEvent;最多 64 页的单周期 cancel supervisor;显式 start/stop、无重叠、错误隔离、停止等待有上限且 timer unref 的 lifecycle runner;Linux durable handle 的 PID/boot/start ticks/process-group 复验与 TERM/KILL controller;完整有界分页且 fail-closed 的 startup Reconcile supervisor;RunningInstance nullable `run_id/attempt_id` 关联;Primary 专用组合 Repository 在同一 SQLite 事务提交前投影 Crontab/RunningInstance,失败整体回滚;有界且防穿越的 legacy log output ref;manual owner seam、真实本机装配、单 spawn/fail-closed;严格 manual-only rollout manifest loader、短期审批/gate、配置哈希审计;HTTP worker 已接轻量 lazy bootstrap,accepted 后按 receipt-first reconcile→completion receipt lifecycle→timeout intent lifecycle→cancel dispatch lifecycle→router 顺序激活,失败撤销,监听失败和 shutdown 有界停止;Primary timeout 在 spawn 前持久化绝对 deadline,有界 source/requester/supervisor 只提交 timeout 意图并复用 CancellationDispatch;代码级 edge/standalone Profile 为各 lifecycle 提供不同 cadence 与页上限,cluster-control/worker 拒绝误装本机 SQLite Primary;统一 CompletionService 原子提交 Attempt/Run/双 Event,spawn 前保存 callback token hash、终态推进 sequence,实时回调与 receipt consumer 共享入口并覆盖两个清理 crash window;manual Primary 已接入受限 POSIX launcher、`0600` direct-file stdout/stderr、父进程退出后续写、不可覆盖 receipt 生产、回执环境清除、TERM 转发等待及 live transaction 后清理;Startup Reconciler receipt-first 双检查并在确定 exited 后执行 profile 化的单次 50/100 ms publish grace;`0007` 独立 CompletionReceiptJournal 在 spawn 前登记、为升级前 active Attempt 补登记并驱动周期扫描,使终态残留继续可发现;确定无效的已知 Attempt receipt 先持久化隔离状态,再进入确定性私有分片 quarantine;终态 missing 与 quarantine 按 edge/standalone retention 有界清理;非 Journal 文件具备只读优先、固定分片/条目上限、overflow fail-closed、显式同盘隔离的 Node 24 运维 CLI;扫描具备页上限、resume cursor、timer unref、无重叠、有界 stop 和低敏计数;ENOSPC 与 launcher receipt 存储失败有代码门禁 | 部署配置写入/审批入口与用户可见状态;PostgreSQL CancellationDispatch adapter;cluster-control 生产启动拓扑;固定 edge/Linux 多架构与真实磁盘压力基线、完整 2.x API 契约和回滚演练 | | PR-7 Worker Session、Run Lease 与启动协议基础 | Incubating(默认关闭,独立入口显式 opt-in) | ADR-0012/0013/0014/0021/0057–0061/0108–0121/0231–0239/0377;有界 capability/Placement/Dispatcher;SQLite 协议孵化与 PostgreSQL v9 Session/Run Lease/credential/attestation authority;immutable revision Placement、数据库时钟 keyset candidate、认证 Worker Pull、digest-only offer recovery;versioned capability-free ExecutionSpec response、stable claim 跨重启退避、单 owner 原子 inbox 准入与 TLS 1.3 mTLS/`ql3w` HTTPS client;同一 package journal 上 revision-fenced starting/spawn/started/running/completion 状态、callback digest、tagged no-spawn 与 ambiguous recovery;PostgreSQL starting/running/start-failure/completion 数据库权威事务、精确重放与 cancellation/timeout 优先终态;batch Secret delivery 在 Attempt advisory lock 下复验 Session/Lease/revision 完整围栏并复用单 Agent,Secret-before-Artifact materializer 将同一 log ID 交给 Executor/journal/running ACK;offer-scoped `wlog-*` 私有文件 spool、Edge/Node 容量策略、append/quota/path 防护、barrier 后 output ownership、受审 POSIX Executor、truncation fact、固定内存流式 source、认证 Artifact stream、共享 immutable store port、S3-compatible SSE/checksum/条件 promotion adapter、upload-before-completion 协调,以及 Local/Cluster 同构、Profile-aware、ETag-fenced range read;用户取消 run.stop mutation 以数据库时间写 intent/Event 并在事务内复验 Project/RoleBinding fence;非执行取消 convergence lifecycle、运行期 expiry 与安全 lost retry 已接入 cluster-control 单一全局 cadence;完整 generation/version/token/Attempt fencing;独立最小权限 Worker ingress、CA/CRL 与连接 generation 热重载;offer journal、spawn barrier、receipt-first recovery;独立 `@qinglong/worker-runtime` 的本地 P-256 CSR、key/chain/trust 验证、generation + active pointer 安装和持久退避;默认关闭的 production process 已装配具体 execution graph、完整 Session heartbeat/drain/offline、direct-file bootstrap、单 Agent/单 cadence、startup reconciliation、证书 maintenance、transport fail-close/recovery 与 Edge/Node 有界预算;真实 PostgreSQL 18 + Linux Node 合约已覆盖 Run completion、credential 和 CA 双轮换且保持同一 Session;真实 K3s 合约已覆盖 TLS/credential Secret 分权、双对象 CAS、Recreate 顺序、identity generation 与单节点 PVC recovery;所有能力默认不可达且受 edge/cluster import audit 约束 | 具体 cert-manager/Vault/SPIFFE/离线 CA adapter 与模板、ingress reload controller、生产 RBAC、证书到期告警和 `ql3w` credential recovery 产品面;具体 KMS/Vault Secret provider、对象存储 credential/temporary lifecycle 与 retention/tombstone;Worker 管理 API;真实 Kubernetes 多节点 CSI/node-loss/production 360 秒 drain 与固定 edge 文件系统 suspend/时钟/断电、x64/arm64 资源门禁 | | PR-8 Project/Policy/Approval Core | Incubating(默认拒绝、无生产业务执行入口) | ADR-0028;统一六类 ActorRef 与 exact-shape 校验;`0017` ownerless default Project 和 append-only versioned RoleBinding;owner/admin/operator/viewer 固定矩阵;Project 内 mutation 幂等、expected-version CAS、双 SQLite 连接竞争门禁;archived read-only、revocation、存储损坏 fail-closed;Agent 写/Secret/Tool `require_approval`;ADR-0047 把六类 subject、role/permission matrix 与 fence 抽到 runtime-core,`pg-0004-project-policy`/capability v3 建立 ownerless PostgreSQL baseline、严格 role/state CHECK、append-only runtime 权限、SERIALIZABLE Project lock、mutation replay、双连接单 winner 和 cluster admission authorizer;ADR-0049/`pg-0005` capability v4 建立 stable IdentitySubject、append-only digest-only API credential、真实 cluster bearer authenticator、write-only durable security audit 与最小权限 runtime role,且已验证 HTTP→credential→Policy→audit→handler 纵向链路;ADR-0051 建立 `/api/v3` 认证前 peer/global 双预算、transport-peer-only、无 timer 且有界内存的 overload shield;ADR-0027 Artifact authorizer adapter;ADR-0029 `AuthenticatedPrincipal` contract、`0018` digest-only versioned challenge、CSPRNG/TTL、同事务消费 challenge + 写首 owner、精确重放与双连接竞争/崩溃回滚门禁;ADR-0030 `0019` stable identity/binding、legacy HS384 + current-session membership、logout/platform/revoke/disable、single-factor 与损坏 fail-closed 门禁;ADR-0031 `0020` digest-bound ApprovalRequest、User-only decision、Project/Role version fence、精确 expiry/重放/并发裁决及同事务 immutable dispatch;ADR-0032 `0021` execution backfill、三表原子 consume、稳定 due keyset、claim/renew/start/result fencing、pre-start takeover/post-start recovery-required、attempt budget、handler inspect/digest barrier 和 bounded dispatcher;ADR-0033/`0022` control/resolution backfill、start/renew/completion 原子联动、稳定 recovery keyset、双 resolver claim/takeover、finding/result 精确重放、自动/人工终结、迟到 completion 单 winner 和 evidence-only bounded reconciler;ADR-0034/`0023` 首个 `run.create` canonical plan、Run/Attempt/Event/receipt 同事务、幂等 collision fail-closed、renew/终态 fence、真实 SQLite handler 与 automatic evidence provider;ADR-0035/`0024` 独立 `approval.recover` 矩阵、稳定 User + 五分钟强认证、Project/RoleBinding fence、human resolution + authorization fact 原子提交、撤权竞态与回滚门禁;ADR-0036 recovery-first 单 timer lifecycle、edge/standalone 独立 cadence/页预算、跨周期 cursor、非重叠与有界 stop;ADR-0074 以新的 Node 24 SQLite v5 ownerless Project/RoleBinding/audit authority 和独立 local-secret-admin 提供强 Principal、`secret.manage`、撤权 fence、envelope+allowed audit 原子提交及不回显语义;ADR-0086 以可信 POSIX console 和 staged delivery 完成本机首 Owner 产品 ceremony | fresh database/pepper setup 与安全迁移向导;`shareStore`/Express 到 authentication core 的 production migration;credential rotation/revocation API、mTLS/Worker enrollment、恢复码;Project/Role/Approval/Secret 管理 CLI/API/UI、audit retention/query/export/alert、preview Artifact/digest/immutable plan builder、真实 MFA/hardware adapter、人工 recovery API/UI/独立 rate limit 与审计事件、handler/provider registry、lifecycle startup/shutdown/指标/admission gate;PostgreSQL action/receipt/provider/recovery-authorization 与 OPA adapter、缓存 version 失效;Tool/Package/Secret/Shell 各自的 handler/evidence contract;Secret/Run/Tool/Workflow waiting_approval 全入口装配;完整回滚演练 | diff --git a/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md b/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md index 629ff937..493c0250 100644 --- a/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md +++ b/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md @@ -5,7 +5,7 @@ - 决策者:QingLong Maintainers - 关联 RFC:[QL-RFC-0001](../QINGLONG_3_0_ARCHITECTURE_RFC.md) - 前置决策:[ADR-0001](./ADR-0001-run-state-and-transaction-boundaries.md) -- Amended by:[ADR-0445](./ADR-0445-schedule-service-origin-shadow-run-coverage.md)、[ADR-0446](./ADR-0446-system-crond-stable-shadow-admission.md) +- Amended by:[ADR-0445](./ADR-0445-schedule-service-origin-shadow-run-coverage.md)、[ADR-0446](./ADR-0446-system-crond-stable-shadow-admission.md)、[ADR-0447](./ADR-0447-boot-shadow-and-non-origin-boundaries.md) ## 1. 决策摘要 @@ -17,7 +17,8 @@ QingLong 3.0 采用按执行来源渐进切换的三态兼容模式: - shadow:2.x 的 Crontab、RunningInstance、Shell 回调和日志仍是用户可见事实源;系统旁路创建 Run、RunAttempt 和 RunEvent,用于验证,不参与调度与结果判定。 - primary:Run 聚合成为事实源,2.x 字段变为兼容投影;所有外部副作用由新 Runtime 发起,Legacy 路径不得再次执行同一任务。 -切换按 manual、scheduled、once、boot、grpc 等 execution origin 独立进行,不允许一次性全局切换。任何单次执行在被接受时即固定 owner 为 legacy 或 runtime,运行中不得切换执行引擎。 +切换按已被入口事实证明的 manual、scheduled、boot 与内部任务 execution origin 独立进行,不允许一次性全局切换。`once`、`grpc` 等保留值只有在未来具备独立 +trigger/admission identity 后才形成切换单元,不能由 schedule 或 transport 名称推导。任何单次执行在被接受时即固定 owner 为 legacy 或 runtime,运行中不得切换执行引擎。 Shadow 写入必须 fail-open:新模型写入失败只产生有界日志、指标和对账记录,不改变 2.x 的执行结果。Primary 路径在产生 spawn、派发或其他外部副作用前必须 fail-closed;只有能够证明外部副作用尚未发生时,才允许受控回退到 Legacy。 @@ -145,18 +146,19 @@ owner 在接受触发时写入执行上下文,并贯穿日志、指标和回 当前孵化实现只开放观察型 Shadow,不通过该环境变量提供 primary: - QL3_SHADOW_ORIGINS=manual,scheduled_node,scheduled_system,subscription,system,script + QL3_SHADOW_ORIGINS=manual,scheduled_node,scheduled_system,boot,subscription,system,script - 未设置或设置为空时全部为 off。 -- ADR-0446 后当前接受 `manual`、`scheduled_node`、`scheduled_system`、`subscription`、`system` 与 `script`;未知 origin 被忽略并记录 - 有界配置告警,`once`、`boot` 与 `grpc` 仍不开放。 +- ADR-0447 后当前接受 `manual`、`scheduled_node`、`scheduled_system`、`boot`、`subscription`、`system` 与 `script`;未知 origin 被忽略并记录 + 有界配置告警。`once` 与 `grpc` 仍是保留领域值:现有 `@once` 只是 schedule 标记,gRPC 只是传输入口,两者都不能单凭该字段推导 execution origin。 - 配置在进程内首次使用时读取;edge 不启动 watcher,变更后需要通过既有进程重启或未来的显式 reload 生效。 - 兼容观察器在实际 HTTP/gRPC worker 中按需加载;关闭时不构造 Shadow 事实或任务摘要、不增加 ChildProcess 监听器、不初始化 Repository、不创建后台任务,也不引入额外数据库写入。 -- `manual/scheduled_node/subscription/system/script` 只监听 Legacy 已创建的同一个 ChildProcess。`scheduled_system` 不持有 Node ChildProcess, +- `manual/scheduled_node/boot/subscription/system/script` 只监听 Legacy 已创建的同一个 ChildProcess。`scheduled_system` 不持有 Node ChildProcess, 只接受 system crond 显式标记后由 Shell start/finish 共用的稳定 execution ID;finish-only 回调可以幂等补齐 accepted→terminal 聚合。Shadow 代码不得调用 Executor 或第二次 spawn;`subscription/system/script` 仅在 `ScheduleService` 已选中执行且 `onBefore` 成功后 accepted。 - 任意初始化、接受或后续写入失败都退化为 no-op,只记录不含命令、环境变量和 Secret 的稳定错误类型与有界计数。 -- `boot` 虽复用 `runSingle`,仍携带独立 origin,当前不在允许列表中,不能被误记为 manual。 +- `bootTask` 只把启用的 `@boot` 条目以固定 `boot` origin 交给 `runSingle`;普通 HTTP/gRPC `run` 仍使用 `manual`。`@once` 不会因 schedule + 字符串被改记为 `once`,gRPC 请求也不会因 transport 被改记为 `grpc`。 该环境变量是 Alpha 兼容桥,不替代最终可审计的 `RuntimeRolloutConfig`。进入 primary 前必须改用具备配置校验、审计和 owner 固化语义的正式配置面。 @@ -488,12 +490,13 @@ Shadow 阶段至少记录: 2. manual shadow 3. scheduled_node shadow 4. scheduled_system shadow - 5. once/boot/grpc shadow - 6. manual primary for opt-in users - 7. manual primary default - 8. scheduled_node primary - 9. scheduled_system primary - 10. remove Legacy as execution owner + 5. boot shadow + 6. future source-proven trigger shadow(只有独立 once/grpc trigger 存在时) + 7. manual primary for opt-in users + 8. manual primary default + 9. scheduled_node primary + 10. scheduled_system primary + 11. remove Legacy as execution owner 每一步必须独立通过门禁,不能因为 manual 路径稳定就直接切换 scheduled/system crontab。 diff --git a/docs/adr/ADR-0447-boot-shadow-and-non-origin-boundaries.md b/docs/adr/ADR-0447-boot-shadow-and-non-origin-boundaries.md new file mode 100644 index 00000000..c184d17b --- /dev/null +++ b/docs/adr/ADR-0447-boot-shadow-and-non-origin-boundaries.md @@ -0,0 +1,67 @@ +# ADR-0447:Boot Shadow 准入与非 Origin 边界 + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-02、D-355、PR-4 +- 关联 ADR:ADR-0001、ADR-0002、ADR-0445、ADR-0446 +- Amends:ADR-0002 的 Alpha Shadow allowlist 与 `once/boot/grpc` 裁决 + +## 上下文 + +Run domain 为未来触发协议保留了 `once`、`boot` 和 `grpc` execution origin,但 2.x 代码里的相似名称并不都代表独立的执行所有权边界: + +- `CronService.bootTask` 在启动后筛选启用的 `@boot` Crontab,并显式调用 `runSingle(id, 'boot')`;这是一条真实、独立且由 Legacy Node worker + 创建 ChildProcess 的触发路径。 +- `@once` 当前只被 `isSpecialSchedule` 排除出 system/node 自动调度,没有独立自动触发器。用户从 HTTP 或 gRPC API 运行这类 Crontab 时仍进入 + `CronService.run`,语义是 manual。 +- gRPC `runCrons` 只是传输适配器,委托同一个 `CronService.run(ids)`;transport 不能替代 actor、trigger 与 execution owner。 + +如果仅因为领域枚举或 schedule/transport 名称存在就同时开放三类 origin,会把同一次 manual 执行错误分类,污染 Shadow 完整率和未来 Primary 门禁。 + +## 决策 + +1. `QL3_SHADOW_ORIGINS` 增加 `boot`。默认仍为 off,只有显式列出 `boot` 时才构造事实、加载 Shadow Repository 或增加 ChildProcess listener。 +2. 复用现有 `runSingle` 的唯一 ChildProcess,不调用 Executor、不再次 spawn;Run 固定 `executionOwner=legacy`、`origin/triggerType=boot`、 + `triggeredBy=legacy:boot` 与 `legacy-cron:` task identity。 +3. `bootTask` 只准入启用的 `@boot` 条目;disabled、`@once` 与普通 cron 表达式不进入 boot 路径。一次进程启动中的每个匹配条目只调用一次 + `runSingle(id, 'boot')`,现有并发限制和 Legacy 状态仍是执行事实源。 +4. 不把 `@once` schedule 推导为 `once` origin。当前没有独立 once trigger/admission identity;由用户 API 发起的 `@once` Crontab 仍是 manual。 +5. 不把 gRPC transport 推导为 `grpc` origin。现有 `runCrons` 与 HTTP `/crons/run` 共用 `CronService.run`;未来只有出现具备独立认证 actor、 + accepted identity 和 owner 决策的 gRPC trigger 时,才可另提准入 Gate。 +6. `once`、`grpc` 保留在共享 domain/schema vocabulary 中,避免破坏持久化兼容和未来协议,但 Legacy Shadow allowlist 拒绝它们并产生有界、低敏配置告警。 + +## 资源与部署影响 + +- 不新增 package、生产依赖、schema、migration、表、索引、timer、watcher、线程、端口或部署对象。 +- 默认关闭时只多一个缓存 Set 成员,没有数据库读取、任务摘要、listener 或写入。 +- 启用时只为本来就会执行的 boot ChildProcess 写现有 Run/Attempt/Event;不扫描历史 Crontab,不产生后台对账循环。 +- 路由设备与 standalone 使用相同的惰性进程内路径;cluster 节点不会因此获得新的本机 owner,也不会把 transport 当作跨节点 authority。 + +## 被拒绝的替代方案 + +### 同时开放 once、boot、grpc + +拒绝。只有 boot 有独立的真实触发路径;其余两个名称不能证明 execution origin。 + +### 根据 `cron.schedule === '@once'` 改写 origin + +拒绝。schedule 描述任务定义,不描述本次触发者;用户手动执行 `@once` 任务仍是 manual。 + +### 根据请求来自 gRPC 改写 origin + +拒绝。传输协议不是 owner。HTTP 与 gRPC 当前调用同一 service 方法,按 transport 分裂会让相同行为产生不同 Run 语义。 + +### 为 boot 另建 Executor 或 scheduler + +拒绝。Shadow 只能观察 Legacy 已创建的进程;第二个执行器会违反单 owner 和零双跑约束。 + +## 验证 + +- 环境边界验证 boot 可显式启用,而 once/grpc 继续拒绝;未知配置只产生有界告警。 +- `bootTask` 合同验证只选择 enabled `@boot`,并以固定 `boot` origin 单次派发。 +- 真实 ChildProcess + SQLite 集成验证一个 legacy-owned boot Run/Attempt、成功终态与八个顺序 Event,且不保存原始 command/credential 文本。 +- `@once` 真实执行验证继续产生 manual fact;gRPC `runCrons` 验证只委托 `CronService.run`,没有传入或推导 grpc origin。 +- Legacy Shadow 聚焦测试 42/42、`build:back`、完整 backend 1,419 pass + 2 条条件 skip/0 fail、18-package clean + build/test、14/14 静态审计与 14/14 artifact 档位全部通过;artifact 字节与 D-354 一致。 +- 本阶段没有修改数据库 schema/adapter、容器或 Kubernetes 拓扑,因此不重跑物理 PostgreSQL HA/K3s 门;完整证据与各档位字节记录在 + QL-RFC-0001 D-355。 diff --git a/docs/adr/README.md b/docs/adr/README.md index ce1200aa..5d49f280 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -450,6 +450,7 @@ | [ADR-0444](./ADR-0444-fail-closed-release-tag-finalizer-and-replay-rehearsal.md) | Fail-closed Release Tag Finalizer 与重放演练 | Accepted(首份真实 GHCR response-loss 重放待实际 release tag) | | [ADR-0445](./ADR-0445-schedule-service-origin-shadow-run-coverage.md) | ScheduleService 执行来源的 Shadow Run 覆盖 | Accepted(`scheduled_system` 后续由 ADR-0446 完成) | | [ADR-0446](./ADR-0446-system-crond-stable-shadow-admission.md) | System Crond 稳定 Shadow 准入与回调重放 | Accepted | +| [ADR-0447](./ADR-0447-boot-shadow-and-non-origin-boundaries.md) | Boot Shadow 准入与 once/gRPC 非 Origin 边界 | Accepted | ## 规则 diff --git a/test/back/legacyBootShadow.test.cjs b/test/back/legacyBootShadow.test.cjs new file mode 100644 index 00000000..74e88fa2 --- /dev/null +++ b/test/back/legacyBootShadow.test.cjs @@ -0,0 +1,274 @@ +require('ts-node/register/transpile-only'); + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { after, test } = require('node:test'); +const { Sequelize } = require('sequelize'); +const { Container } = require('typedi'); + +const dataDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-legacy-boot-shadow-'), +); +fs.mkdirSync(path.join(dataDirectory, 'db'), { recursive: true }); +process.env.QL_DATA_DIR = dataDirectory; + +const { CrontabModel, CrontabStatus } = require('../../back/data/cron'); +const { + defineSchemaMigrationModel, +} = require('../../back/data/schemaMigration'); +const CronService = require('../../back/services/cron').default; +const { runCrons } = require('../../back/schedule/api'); +const { runSchemaMigration } = require('../../back/migrations/0002-run-schema'); +const { + runCancellationRequestMigration, +} = require('../../back/migrations/0004-run-cancellation-request'); +const { + runAttemptDeadlineMigration, +} = require('../../back/migrations/0006-run-attempt-deadline'); +const { runMigrations } = require('../../back/migrations/runner'); +const { + LegacySequelizeRunRepository, +} = require('../../back/runtime/adapters/legacy-sequelize/runRepository'); +const { + LegacyShadowRunObserver, +} = require('../../back/runtime/application/legacyShadowRunObserver'); +const { + LegacyShadowRunWriter, +} = require('../../back/runtime/application/legacyShadowRunWriter'); +const { + installLegacyExecutionObserver, +} = require('../../back/runtime/compatibility/legacyExecutionBridge'); +const { + shadowOnlyRollout, +} = require('../../back/runtime/domain/runtimeRollout'); + +after(() => { + fs.rmSync(dataDirectory, { recursive: true, force: true }); +}); + +function logger() { + return { + info() {}, + warn() {}, + error() {}, + }; +} + +function successfulCommand() { + return `${JSON.stringify(process.execPath)} -e "process.exit(0)"`; +} + +function cron(overrides = {}) { + return { + id: 71, + name: 'boot probe', + command: 'credential=must-not-persist', + schedule: '@boot', + status: CrontabStatus.queued, + isDisabled: 0, + extra_schedules: [], + ...overrides, + }; +} + +async function withStubbedCronUpdate(work) { + const original = CrontabModel.update; + const updates = []; + CrontabModel.update = async (...args) => { + updates.push(args); + return [1]; + }; + try { + return await work(updates); + } finally { + CrontabModel.update = original; + } +} + +test('bootTask selects only enabled @boot entries and fixes their origin before dispatch', async () => { + await withStubbedCronUpdate(async (updates) => { + const service = new CronService(logger()); + const dispatched = []; + service.crontabs = async () => ({ + data: [ + cron({ id: 71 }), + cron({ id: 72, isDisabled: 1 }), + cron({ id: 73, schedule: '@once' }), + cron({ id: 74, schedule: '0 * * * *' }), + ], + total: 4, + }); + service.runSingle = (...args) => { + dispatched.push(args); + }; + + await service.bootTask(); + + assert.deepEqual(dispatched, [[71, 'boot']]); + assert.equal(updates.length, 1); + assert.deepEqual(updates[0][0], { status: CrontabStatus.queued }); + assert.deepEqual(updates[0][1], { where: { id: [71] } }); + }); +}); + +test('persists one legacy-owned terminal Run for the real boot child path', async () => { + const database = new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', + logging: false, + }); + const observations = []; + const failures = []; + let idSequence = 900; + + try { + await runMigrations({ + database, + migrationModel: defineSchemaMigrationModel(database), + migrations: [ + runSchemaMigration, + runCancellationRequestMigration, + runAttemptDeadlineMigration, + ], + logger: { info() {} }, + }); + const repository = new LegacySequelizeRunRepository(database); + const writer = new LegacyShadowRunWriter(repository, () => { + idSequence += 1; + return `019f7100-0000-7000-8000-${String(idSequence).padStart(12, '0')}`; + }); + const delegate = new LegacyShadowRunObserver( + shadowOnlyRollout(['boot']), + writer, + { failure: (failure) => failures.push(failure) }, + ); + const restore = installLegacyExecutionObserver( + { + begin(fact) { + const observation = delegate.begin(fact); + observations.push({ fact, observation }); + return observation; + }, + }, + ['boot'], + ); + + try { + await withStubbedCronUpdate(async () => { + const service = new CronService(logger()); + service.getDb = async () => cron(); + service.makeCommand = () => successfulCommand(); + + const result = await service.runSingle(71, 'boot'); + assert.equal(result.code, 0); + assert.equal(observations.length, 1); + await observations[0].observation.settled(); + }); + } finally { + restore(); + } + + const runId = '019f7100-0000-7000-8000-000000000901'; + const attemptId = '019f7100-0000-7000-8000-000000000902'; + const run = await repository.findRunById(runId); + const attempt = await repository.findAttemptById(attemptId); + const events = await repository.listEvents(runId); + const fact = observations[0].fact; + + assert.deepEqual(failures, []); + assert.equal(fact.origin, 'boot'); + assert.equal(fact.triggerType, 'boot'); + assert.equal(fact.triggeredBy, 'legacy:boot'); + assert.equal(fact.legacyCronId, 71); + assert.match(fact.taskRevision, /^sha256:[0-9a-f]{64}$/u); + assert.equal(run.executionOrigin, 'boot'); + assert.equal(run.executionOwner, 'legacy'); + assert.equal(run.status, 'succeeded'); + assert.equal(attempt.status, 'succeeded'); + assert.equal(attempt.executorType, 'legacy_local'); + assert.equal(attempt.pid > 0, true); + assert.equal(attempt.exitCode, 0); + assert.deepEqual( + events.map((event) => event.type), + [ + 'run.created', + 'run.queued', + 'run.dispatching', + 'attempt.starting', + 'attempt.running', + 'run.running', + 'attempt.succeeded', + 'run.succeeded', + ], + ); + assert.equal( + JSON.stringify({ run, attempt, events }).includes('credential='), + false, + ); + } finally { + await database.close(); + } +}); + +test('@once remains a manual trigger instead of becoming an inferred execution origin', async () => { + const facts = []; + const restore = installLegacyExecutionObserver( + { + begin(fact) { + facts.push(fact); + return { + spawned() {}, + running() {}, + startFailed() {}, + exited() {}, + cancelled() {}, + }; + }, + }, + ['manual', 'boot'], + ); + + try { + await withStubbedCronUpdate(async () => { + const service = new CronService(logger()); + service.getDb = async () => cron({ id: 81, schedule: '@once' }); + service.makeCommand = () => successfulCommand(); + + const result = await service.runSingle(81); + assert.equal(result.code, 0); + }); + } finally { + restore(); + } + + assert.equal(facts.length, 1); + assert.equal(facts[0].origin, 'manual'); + assert.equal(facts[0].triggerType, 'manual'); + assert.equal(facts[0].triggeredBy, 'legacy:manual'); +}); + +test('the legacy gRPC run endpoint delegates without inventing a grpc execution origin', async () => { + await withStubbedCronUpdate(async () => { + const service = new CronService(logger()); + const dispatched = []; + service.runSingle = (...args) => { + dispatched.push(args); + }; + Container.set(CronService, service); + + try { + const response = await new Promise((resolve, reject) => { + runCrons({ request: { ids: [91] } }, (error, value) => + error ? reject(error) : resolve(value), + ); + }); + assert.deepEqual(response, { code: 200 }); + assert.deepEqual(dispatched, [[91]]); + assert.equal(dispatched[0].includes('grpc'), false); + } finally { + Container.remove(CronService); + } + }); +}); diff --git a/test/back/legacyScheduleServiceShadow.test.cjs b/test/back/legacyScheduleServiceShadow.test.cjs index 114a036f..174617be 100644 --- a/test/back/legacyScheduleServiceShadow.test.cjs +++ b/test/back/legacyScheduleServiceShadow.test.cjs @@ -45,7 +45,7 @@ function successfulCommand() { return `${JSON.stringify(process.execPath)} -e "process.exit(0)"`; } -test('admits the reviewed schedule and system-crond origins through the environment boundary', () => { +test('admits only reviewed execution origins through the environment boundary', () => { const source = ` const bridge = require('./back/runtime/compatibility/legacyExecutionBridge'); const fact = (origin) => ({ @@ -56,11 +56,11 @@ test('admits the reviewed schedule and system-crond origins through the environm triggerType: origin, acceptedAtMs: 1, }); - const result = ['subscription', 'system', 'script', 'scheduled_system', 'boot'].map((origin) => + const result = ['subscription', 'system', 'script', 'scheduled_system', 'boot', 'once', 'grpc'].map((origin) => Boolean(bridge.observeLegacyExecution(origin, () => fact(origin))), ); process.stdout.write(JSON.stringify(result)); - process.exit(result.join(',') === 'true,true,true,true,false' ? 0 : 1); + process.exit(result.join(',') === 'true,true,true,true,true,false,false' ? 0 : 1); `; const child = spawnSync( process.execPath, @@ -69,7 +69,8 @@ test('admits the reviewed schedule and system-crond origins through the environm cwd: path.resolve(__dirname, '../..'), env: { ...process.env, - QL3_SHADOW_ORIGINS: 'subscription,system,script,scheduled_system', + QL3_SHADOW_ORIGINS: + 'subscription,system,script,scheduled_system,boot,once,grpc', }, encoding: 'utf8', timeout: 10_000, @@ -77,7 +78,10 @@ test('admits the reviewed schedule and system-crond origins through the environm ); assert.equal(child.status, 0, child.stderr); - assert.equal(child.stdout, '[true,true,true,true,false]'); + assert.equal( + child.stdout.trim().split('\n').at(-1), + '[true,true,true,true,true,false,false]', + ); }); test('observes subscription, system and script children without replacing legacy execution', async () => {