From 15d970be4190069c935a0134d5d1ac01ed395e07 Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 2 Sep 2026 06:18:44 +0800 Subject: [PATCH] feat(ql3): add bounded legacy panel cron adapter --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...nded-read-only-local-panel-cron-adapter.md | 59 +++++ docs/adr/README.md | 1 + .../src/admission/localApiAdmission.ts | 21 +- .../localApiProductSurface.ts | 6 + .../panel-compatibility/panelCronListRoute.ts | 163 ++++++++++++ .../src/transport/httpSurface.ts | 65 ++++- .../ql3-local-api/test/admission.test.cjs | 40 +++ .../ql3-local-api/test/httpSurface.test.cjs | 62 ++++- .../test/panelCronListRoute.test.cjs | 245 ++++++++++++++++++ .../test/sqliteHttp.integration.test.cjs | 40 +++ test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 12 files changed, 691 insertions(+), 17 deletions(-) create mode 100644 docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md create mode 100644 packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts create mode 100644 packages/ql3-local-api/test/panelCronListRoute.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index d116b588..2502f7bf 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-427/ADR-0529(源码候选,等待双架构阶段实物):开始把现有 2.x 面板从“不能零改直连”推进为受控页面复用,而不是把旧 Express Service 或数据库写 authority 搬回 3.0。第一切片只在既有 `@qinglong/local-api/panel-compatibility` 子域开放认证后的 `GET /api/crons`:正式 Bearer credential、`task.read` Policy、durable audit、credential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Trigger;command 只返回 `ql3::@` 描述符,不暴露 argv、环境、Secret、label、mutation 或 digest。缺失 pinned Task、identity 漂移、未知 Trigger schema、异常页和超预算均整体失败关闭。首切片只接受旧页面初始空 search/filter 与有界 page/size;搜索、排序、View query 和全部写操作明确拒绝。它不新增 package、dependency、migration、连接、listener、timer 或 watcher;headless 与 Cluster 路径不变,也不提供 2.x login/JWT、静态面板、WebSocket、Cron View、Subscription、Script、Env 或 Run/Log 兼容。本地真实 SQLite/credential/Policy/HTTP 集成、Local API 83/83、18-package clean build/test 和 Edge/Standalone Application API 资源门已通过;仍须完成双架构 artifact 门,并继续实现 capability shell 与 Run/Log read,才能把改造后的现有页面称为阶段性可用。 + - D-426c3/ADR-0528(源码候选,等待 exact 双架构阶段实物):在 D-426c2 的三阶段回滚链上保留 `apply-rollback`,新增显式 `apply-plan` 与 `complete`。完成型 CI 使用完整 2.x schema、空 Apps/Auths、无未知插件表的独立 fixture;外部 completion review 对 blocked 事实直接拒绝,对 Legacy/Target Run History 分别授权 `retain_both/retain_target`,对 Secret/Config 只授权 `manual_external`。`apply-plan` 消费互相独立的 Automation/review decision,依次完成 Automation apply/verify、双侧终态 Run History preservation/verify、Secret/Config plan/verify 和 decision prepare,停在 `secret_config_decision_required`;`complete` 再消费独立 Secret/Config decision,完成 decision/apply/verify,并以 completion v3 同时绑定三类 adapter,要求 `reconciliation_completed`、`adapterCount=3`。Run History 必须位于 Automation apply 与会推进 head 的 Secret/Config plan 之间。completion 后 target/Legacy 仍 stopped,两个 restart 都是 `not_authorized`。针对 readiness 要求 Apps/Auths 必须存在而旧诊断又无条件阻塞 identity 域的矛盾,仅把“Legacy 已知 identity 表全部为空”收窄为 `informational/catalog_evidence`;任一身份行、未知表、目标 identity 或异常仍 fail-closed。Trial Kit/verification/auditor 升为 `@v11/@v9/@v8`、manifest schemaVersion 12,Local milestone 升为 `@v7`/schemaVersion 7,并新增 required `legacyUpgradeReconciliationCompletion=passed`;CI 仍独立保留 Automation apply→rollback 实证,completion 不覆盖 recovery 证据。本切片不增加 package、production dependency、daemon/listener/timer/watcher/连接或稳态资源,默认低配 headless 与 Cluster authority 边界不变。 - D-426c2/ADR-0527(exact headless 双架构阶段实物已交付):downloadable Trial Kit 新增三阶段 canonical `reconciliation-rehearsal.sh`。`prepare` 从 D-426c1 stopped capture 建立 bounded plan/diagnostics/strong-auth review prepare 后停在 `operator_decision_required`;`review` 只消费 owner-private 外部 NDJSON,完成 authorization commit/verify、application plan 与 Automation plan 后停在 `automation_decision_required`;`apply-rollback` 再消费独立外部 Automation row NDJSON,只应用一个无冲突 Automation 行、验证正式 Task/Trigger 投影并显式回滚,终态固定 `reconciliation_automation_rolled_back`。交付脚本永不生成 decision;CI fixture 不进入 bundle,Legacy Run History 固定 `manual_external`。decision 父目录必须 current-UID `0700`、恰好一个 canonical file、与所有 authority roots 不重叠,并整体只读挂载;60 秒 authorization 不得晚于 strong principal,authentication database 必须在异步 confirm 完成后才关闭。target SQLite 必须位于 deployment root 下且避开 reconciliation sibling roots;apply/rollback 额外只读挂载 exact Legacy root,运行在 128 MiB/0.5 CPU/32 PID、无网络、只读 rootfs、drop-all/no-new-privileges 的短生命周期 Operator 中。带时间 command 和成功 result 支持中断后的 exact replay,不得静默重写。Trial Kit/verification/auditor 升为 `@v10/@v8/@v7`、manifest schemaVersion 11,milestone 升为 `@v6`/schemaVersion 6,并增加 required `legacyUpgradeReconciliationAutomationRollback=passed` 与双架构 reconciliation script digest。最终本地 arm64 exact bundle 使用 Application `sha256:eec404d24b5c101871e000caac902d3866e5fe3f0e6dcef31366cb526ef32f80`、无源码覆盖 Operator `sha256:10f75f12d185e5796dcc4a230b6684c074bd9a6e6156ee1110fff1c2d8dd3390`,offline audit 返回 `compatible=true`;全新 2.x fixture 贯通 readiness→stage→cutover→capture→437 条外部 review decision→1 条 Automation decision→apply/verify→rollback/verify,实际采用 1 Task/1 Trigger 后恢复应用前快照,未尝试 Secret/Config、Run History、completion 或任一重启。Docker Desktop 的只读 bind mount UID 瞬时漂移仅通过同一 inspect 一次有界重试收敛,持续错误仍 fail-closed,不放宽 Linux owner proof。首次普通 CI run `33525269537` 在 x64 暴露既有兼容测试的 module-scope `TaskLimit` 异步 SQLite 初始化与临时目录清理竞态,未以重跑掩盖;隔离测试副作用的提交 `c8d9eed95d402aae642e81e60fce336670ac06a0` 后,普通主 CI [run 33526720941](https://github.com/whyour/qinglong/actions/runs/33526720941) 为 41 success/3 expected skip/0 fail,Kubernetes [run 33526721040](https://github.com/whyour/qinglong/actions/runs/33526721040) 成功,显式 Local headless [run 33528370769](https://github.com/whyour/qinglong/actions/runs/33528370769) 为 42 success/2 scope skip/0 fail。该 run 交付 amd64/arm64/milestone artifact `9809046864`/`9809000920`/`9809293769`,大小 `226266116`/`221665796`/`6734` bytes,GitHub digest 为 `sha256:bffabf76c9d7b599c5ac65dd4ff7aa8f65af6a3167a68afb497a9c8967f34c36`、`sha256:8b74e5e9e1437962185fb87e7b0208bf157d063af28dfb1676c59b9785b937b8`、`sha256:a69de90a07bc36a397b08bb567739a001ac2f830fed73860255db7de175b3527`,保留至 2026-10-01;milestone v6 下载后 auditor 返回 `compatible=true`,并绑定双架构 reconciliation script digest。该切片不新增 package、production dependency、daemon/listener/timer/watcher/连接或稳态资源;低配路由设备默认 headless 不变,Cluster 不复用 Local SQLite/POSIX/Docker authority,2.x 老面板仍需独立 API/认证/领域 adapter,不能零改直连。 diff --git a/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md b/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md new file mode 100644 index 00000000..f111eafd --- /dev/null +++ b/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md @@ -0,0 +1,59 @@ +# ADR-0529:有界只读 Local 旧面板 Cron Adapter + +- 状态:Proposed(源码候选,尚未进入双架构阶段实物) +- 日期:2026-09-02 +- 关联 RFC:QL-RFC-0001 D-427、D-423、D-424、D-426c3 + +## 背景 + +QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络读取定时任务;QingLong 3.0 Local API 则以 Project-scoped Task、immutable Trigger revision 和 `/api/v3` 为权威。当前 Console Alpha 已经可以操作 Task、Trigger、Run、日志和 Secret,但它携带的是小型离线 Console,不包含 33 MiB 的 2.x 静态面板,也不能把 2.x JWT、明文 Env、整数 ID 或旧 Service 直接当作 3.0 authority。 + +直接让旧面板写入 3.0 SQLite 会绕过 Project Policy、credential reconfirm、durable audit、revision/content digest fence,并重新耦合已经分离的 Task 与 Trigger。另一方面,要求所有部署者立即迁移到新 Console 会阻断现有页面的渐进复用。因此需要一个显式、窄面、可逐步扩展的 Adapter,而不是恢复完整旧后端。 + +## 决策 + +第一切片在既有 `@qinglong/local-api` 内新增 `panel-compatibility/` 子域,不新增 workspace package、依赖、数据库表、连接、listener、timer、watcher 或后台进程。 + +### HTTP 与权限边界 + +- 仅新增 `GET /api/crons`;没有 POST、PUT、DELETE、login、session、WebSocket 或静态面板分发。 +- 请求仍走正式 Local API Bearer credential、`task.read` Project Policy、durable security audit 和 credential reconfirm;审计 operation 固定为 `panel.cron.list`。 +- 第一切片固定投影 `default` Project。多 Project 选择必须在后续 capability/session 设计中显式增加,不能从未受信 Header、Cookie 或查询参数猜测。 +- 默认 headless Application 不包含 `@qinglong/local-api`,因此没有新增端口或稳态开销。Cluster 不复用本 Adapter,后续使用独立 Cluster Panel Gateway。 + +### 有界查询 + +- 接受旧页面初始读取所需的 `page`、`size`、空 `searchValue`、空 `filters={}` 和 Axios cache-buster `t`。 +- 非空搜索、排序、View query 或其他字段暂时返回 `400 invalid_panel_cron_list_query`,不能静默忽略并给出错误结果。 +- `size` 最大 64;`page * size` 在 Edge 最大 64、Standalone 最大 256。Adapter 用同一上限向 Trigger source 做一次有界 keyset 前缀读取,再截取所需页。 +- `total` 是当前已观察前缀加一个 `truncated` 继续标记;它足以让旧分页逐页推进,但不执行无界 COUNT 或全表扫描。 + +### 领域映射 + +- 每个 `qinglong/cron@v1` Trigger 投影为一条旧面板 Cron 行,稳定 `id` 使用 `triggerId` 字符串,不构造有碰撞风险的伪数值 ID。 +- Adapter 按 Trigger 固定的 `taskId + taskRevision + taskContentDigest` 读取 pinned Task revision,并复算 Trigger/Task record 与 cron semantic;缺失、漂移、未知 Trigger schema 或异常页整体返回 503。 +- `schedule`、timezone、misfire policy 来自已规范化 Trigger;名称、启停状态来自 pinned Task 与 Trigger 的合成结果。 +- `command` 只返回 `ql3::@` 描述符,不返回 Task spec、argv、环境、Secret、label、mutation ID 或 content digest。 +- 返回行附带只读 `ql3` identity,明确 Project、Task/Trigger revision 与 `readOnly=true`;旧页面当前未知字段会忽略它,后续改造版面板可据此关闭写按钮。 + +## 明确不做 + +本 ADR 不声明现有 2.x 面板可以零修改登录或完整运行。尤其不允许: + +- 把 `ql3c_` credential 放入 2.x 登录密码字段或长期存入 Local Storage; +- 复用 2.x Auths/Users/JWT 作为 3.0 Identity/Policy; +- 猜测 `/api/crons` 写操作、整数 ID、Cron View、Subscription 或 Script 文件语义; +- 回显 Secret/Env 明文,或将 Task 与 Trigger 的独立 revision 压回一个可直接覆盖的旧对象; +- 为兼容页面扩大 loopback、CSP、response byte、并发或低配资源预算。 + +## 验证与后续门禁 + +源码候选必须通过: + +1. Adapter 单测:分页、禁用合成、pinned identity、未知 schema、预算和 storage failure; +2. HTTP 契约:编码的 `{}` 查询、正式 operation 解析、拒绝非空搜索且不进入 Admission; +3. Admission 契约:authenticate → `task.read` → audit → confirm → route; +4. 真实 SQLite 集成:正式 credential、Policy、Task/Trigger revision 和 durable audit,且响应不出现真实 argv; +5. Local API 全包、18-package build/test、dependency/import、Console/Headless image 与双架构 artifact 门。 + +后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。只有改造版面板取消 Local Storage credential、按 capability 隐藏未实现页面,并完成真实浏览器 journey 后,才能声明“现有面板页面可复用”;完整 2.x 零改兼容不作为 3.0 目标。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 5f44fcf0..47bf8fc6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -532,6 +532,7 @@ | [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(同源 exact Console 双架构阶段实物已交付) | | [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Accepted(exact headless 双架构阶段实物已交付) | | [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | Accepted(D-426c3 源码候选;双架构阶段实物待 gate) | +| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | Proposed(D-427 源码候选;双架构阶段实物待 gate) | ## 规则 diff --git a/packages/ql3-local-api/src/admission/localApiAdmission.ts b/packages/ql3-local-api/src/admission/localApiAdmission.ts index 4e2b92f4..69565a64 100644 --- a/packages/ql3-local-api/src/admission/localApiAdmission.ts +++ b/packages/ql3-local-api/src/admission/localApiAdmission.ts @@ -37,6 +37,7 @@ import type { LocalApiSecretListRoute, LocalApiSecretPutRoute, } from '../secret/secretRoutes'; +import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute'; import type { LocalApiResponse } from '../transport/contract'; export type LocalApiAdmissionOperation = @@ -125,6 +126,13 @@ export type LocalApiAdmissionOperation = | Readonly<{ operationId: 'secret.put'; projectId: string; + }> + | Readonly<{ + operationId: 'panel.cron.list'; + projectId: string; + page: number; + size: number; + maximumRows: number; }>; export interface LocalApiAdmissionRequest { @@ -168,6 +176,7 @@ export interface LocalApiAdmissionOptions { readonly triggerPutRoute: LocalApiTriggerPutRoute; readonly secretListRoute: LocalApiSecretListRoute; readonly secretPutRoute: LocalApiSecretPutRoute; + readonly panelCronListRoute: PanelCronListRoute; readonly now?: () => number; readonly randomUuid?: () => string; } @@ -253,6 +262,7 @@ export function createLocalApiAdmission( typeof options.triggerPutRoute?.handle !== 'function' || typeof options.secretListRoute?.handle !== 'function' || typeof options.secretPutRoute?.handle !== 'function' || + typeof options.panelCronListRoute?.handle !== 'function' || (options.now !== undefined && typeof options.now !== 'function') || (options.randomUuid !== undefined && typeof options.randomUuid !== 'function') @@ -393,7 +403,8 @@ export function createLocalApiAdmission( : request.operation.operationId === 'task.list' || request.operation.operationId === 'task.get' || request.operation.operationId === 'trigger.list' || - request.operation.operationId === 'trigger.get' + request.operation.operationId === 'trigger.get' || + request.operation.operationId === 'panel.cron.list' ? 'task.read' : request.operation.operationId === 'secret.list' ? 'secret.manage' @@ -553,6 +564,14 @@ export function createLocalApiAdmission( ? { after: request.operation.after } : {}), }); + case 'panel.cron.list': + if (body !== null) return response(400, 'invalid_request_body'); + return options.panelCronListRoute.handle({ + projectId: request.operation.projectId, + page: request.operation.page, + size: request.operation.size, + maximumRows: request.operation.maximumRows, + }); case 'task.put': case 'task.authoring': case 'trigger.put': diff --git a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts index fd7d4343..97e40aa4 100644 --- a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts +++ b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts @@ -31,6 +31,7 @@ import { createLocalApiSecretListRoute, createLocalApiSecretPutRoute, } from '../secret/secretRoutes'; +import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute'; import { startLocalApiHttpSurface } from '../transport/httpSurface'; export interface LocalApiProductSurfaceEvent { @@ -221,6 +222,10 @@ export function createLocalApiProductSurface( ? {} : { randomUuid: options.randomUuid }), }); + const panelCronListRoute = createPanelCronListRoute({ + tasks: authority.taskDefinitions, + triggers: authority.triggers, + }); const admission = createLocalApiAdmission({ authenticator, policy, @@ -241,6 +246,7 @@ export function createLocalApiProductSurface( triggerPutRoute, secretListRoute, secretPutRoute, + panelCronListRoute, ...(options.now === undefined ? {} : { now: options.now }), ...(options.randomUuid === undefined ? {} diff --git a/packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts b/packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts new file mode 100644 index 00000000..7a46c8a5 --- /dev/null +++ b/packages/ql3-local-api/src/panel-compatibility/panelCronListRoute.ts @@ -0,0 +1,163 @@ +import { + BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA, + createBuiltInTriggerSpecSemanticRegistry, + normalizeTriggerRecord, + type TriggerSource, +} from '@qinglong/runtime-core/trigger'; +import { + normalizeTaskDefinitionRecord, + type TaskDefinitionSource, +} from '@qinglong/runtime-core/task-definition'; + +import type { LocalApiResponse } from '../transport/contract'; + +const MAX_PANEL_PAGE_SIZE = 64; + +export interface PanelCronListRequest { + readonly projectId: string; + readonly page: number; + readonly size: number; + readonly maximumRows: number; +} + +export interface PanelCronListRoute { + handle(request: Readonly): Promise; +} + +export interface PanelCronListSources { + readonly tasks: Pick; + readonly triggers: Pick; +} + +function response( + statusCode: number, + body: Readonly>, +): LocalApiResponse { + return Object.freeze({ statusCode, body: Object.freeze(body) }); +} + +function validRequest(request: Readonly): boolean { + return ( + typeof request.projectId === 'string' && + request.projectId.length > 0 && + Buffer.byteLength(request.projectId, 'utf8') <= 128 && + Number.isSafeInteger(request.page) && + request.page >= 1 && + Number.isSafeInteger(request.size) && + request.size >= 1 && + request.size <= MAX_PANEL_PAGE_SIZE && + Number.isSafeInteger(request.maximumRows) && + request.maximumRows >= 1 && + request.maximumRows <= 256 && + request.page * request.size <= request.maximumRows + ); +} + +export function createPanelCronListRoute( + sources: Readonly, +): Readonly { + if ( + !sources || + typeof sources !== 'object' || + Array.isArray(sources) || + typeof sources.tasks?.findTaskDefinitionRevision !== 'function' || + typeof sources.triggers?.listTriggers !== 'function' + ) { + throw new TypeError('Panel Cron list sources are invalid'); + } + const semantics = createBuiltInTriggerSpecSemanticRegistry(); + return Object.freeze({ + async handle(request: Readonly) { + if (!validRequest(request)) { + return response(400, { code: 400, message: '参数错误' }); + } + try { + const scanLimit = request.page * request.size; + const page = await sources.triggers.listTriggers({ + projectId: request.projectId, + limit: scanLimit, + }); + if ( + !page || + !Array.isArray(page.triggers) || + page.triggers.length > scanLimit || + typeof page.truncated !== 'boolean' || + page.truncated !== Boolean(page.next) + ) { + throw new TypeError('Trigger page is unavailable'); + } + const start = (request.page - 1) * request.size; + const selected = page.triggers.slice(start, scanLimit); + const data: Record[] = []; + for (const rawTrigger of selected) { + const trigger = normalizeTriggerRecord(rawTrigger); + if ( + trigger.projectId !== request.projectId || + trigger.spec.schema !== BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA + ) { + throw new TypeError('Trigger cannot be projected as a Cron'); + } + const spec = semantics.normalize({ + projectId: trigger.projectId, + triggerId: trigger.triggerId, + taskId: trigger.taskId, + taskRevision: trigger.taskRevision, + spec: trigger.spec, + }); + const rawTask = await sources.tasks.findTaskDefinitionRevision( + request.projectId, + trigger.taskId, + trigger.taskRevision, + ); + if (!rawTask) throw new TypeError('Pinned Task is unavailable'); + const task = normalizeTaskDefinitionRecord(rawTask); + if ( + task.projectId !== request.projectId || + task.taskId !== trigger.taskId || + task.revision !== trigger.taskRevision || + task.contentDigest !== trigger.taskContentDigest + ) { + throw new TypeError('Pinned Task identity is detached'); + } + const disabled = !task.enabled || !trigger.enabled; + data.push( + Object.freeze({ + id: trigger.triggerId, + name: task.name, + command: `ql3:${task.kind}:${task.taskId}@${task.revision}`, + schedule: spec.config.expression, + extra_schedules: Object.freeze([]), + status: disabled ? 2 : 1, + isDisabled: disabled ? 1 : 0, + isPinned: 0, + createdAt: new Date(trigger.createdAtMs).toISOString(), + updatedAt: new Date(trigger.updatedAtMs).toISOString(), + ql3: Object.freeze({ + projectId: request.projectId, + taskId: task.taskId, + taskRevision: task.revision, + triggerId: trigger.triggerId, + triggerRevision: trigger.revision, + timezone: spec.config.timezone, + misfirePolicy: spec.config.misfirePolicy, + readOnly: true, + }), + }), + ); + } + return response(200, { + code: 200, + data: Object.freeze({ + data: Object.freeze(data), + total: page.triggers.length + (page.truncated ? 1 : 0), + }), + }); + } catch { + return response(503, { + code: 503, + message: 'QL3 面板兼容视图暂不可用', + }); + } + }, + }); +} diff --git a/packages/ql3-local-api/src/transport/httpSurface.ts b/packages/ql3-local-api/src/transport/httpSurface.ts index e7d2701a..579e2de3 100644 --- a/packages/ql3-local-api/src/transport/httpSurface.ts +++ b/packages/ql3-local-api/src/transport/httpSurface.ts @@ -64,7 +64,8 @@ type LocalApiRouteResolution = | 'invalid_run_log_read_query' | 'invalid_task_list_query' | 'invalid_trigger_list_query' - | 'invalid_secret_list_query'; + | 'invalid_secret_list_query' + | 'invalid_panel_cron_list_query'; }>; export interface LocalApiHttpSurfaceOptions { @@ -549,6 +550,62 @@ function parseRunAttemptLogReadQuery( return Object.freeze({ offset, length }); } +function parsePanelCronListRoute( + rawUrl: string, + profile: LocalApplicationProfile, +): LocalApiRouteResolution | null { + const separator = rawUrl.indexOf('?'); + if ( + separator !== rawUrl.lastIndexOf('?') || + (separator < 0 ? rawUrl : rawUrl.slice(0, separator)) !== '/api/crons' + ) { + return null; + } + try { + const query = new URLSearchParams( + separator < 0 ? '' : rawUrl.slice(separator + 1), + ); + const allowed = new Set(['filters', 'page', 'searchValue', 'size', 't']); + for (const key of query.keys()) { + if (!allowed.has(key) || query.getAll(key).length !== 1) { + throw new TypeError(); + } + } + const searchValue = query.get('searchValue') ?? ''; + const filters = query.get('filters') ?? '{}'; + const rawPage = query.get('page') ?? '1'; + const rawSize = query.get('size') ?? '20'; + const timestamp = query.get('t'); + const page = Number(rawPage); + const size = Number(rawSize); + const maximumRows = profile === 'edge' ? 64 : 256; + if ( + searchValue !== '' || + filters !== '{}' || + !Number.isSafeInteger(page) || + page < 1 || + String(page) !== rawPage || + !Number.isSafeInteger(size) || + size < 1 || + size > 64 || + String(size) !== rawSize || + page * size > maximumRows || + (timestamp !== null && !/^\d{1,20}$/u.test(timestamp)) + ) { + throw new TypeError(); + } + return Object.freeze({ + operationId: 'panel.cron.list', + projectId: 'default', + page, + size, + maximumRows, + }); + } catch { + return Object.freeze({ errorCode: 'invalid_panel_cron_list_query' }); + } +} + function route( request: IncomingMessage, profile: LocalApplicationProfile, @@ -558,11 +615,15 @@ function route( typeof rawUrl !== 'string' || rawUrl.length < 1 || Buffer.byteLength(rawUrl, 'utf8') > MAX_URL_BYTES || - rawUrl.includes('%') || rawUrl.includes('#') ) { return null; } + if (request.method === 'GET') { + const panelCronList = parsePanelCronListRoute(rawUrl, profile); + if (panelCronList) return panelCronList; + } + if (rawUrl.includes('%')) return null; const separator = rawUrl.indexOf('?'); if (separator !== rawUrl.lastIndexOf('?')) return null; const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator); diff --git a/packages/ql3-local-api/test/admission.test.cjs b/packages/ql3-local-api/test/admission.test.cjs index 661c961d..7a2841c3 100644 --- a/packages/ql3-local-api/test/admission.test.cjs +++ b/packages/ql3-local-api/test/admission.test.cjs @@ -303,6 +303,17 @@ function fixture(overrides = {}) { return { statusCode: 201, body: { status: 'inserted' } }; }, }, + panelCronListRoute: { + async handle(value) { + events.push( + `panel-crons:${value.projectId}:${value.page}:${value.size}:${value.maximumRows}`, + ); + return { + statusCode: 200, + body: { code: 200, data: { data: [], total: 0 } }, + }; + }, + }, now: () => 10_000, randomUuid: () => '019f70c0-0000-4000-8000-000000000002', ...overrides, @@ -508,6 +519,35 @@ test('uses task.read with a route-owned task.get audit identity', async () => { ]); }); +test('uses task.read and the exact panel Cron audit identity for the compatibility projection', async () => { + const { admission, events } = fixture(); + assert.deepEqual( + await execute( + admission, + request({ + operation: Object.freeze({ + operationId: 'panel.cron.list', + projectId: 'default', + page: 1, + size: 20, + maximumRows: 64, + }), + }), + ), + { + statusCode: 200, + body: { code: 200, data: { data: [], total: 0 } }, + }, + ); + assert.deepEqual(events, [ + 'authenticate', + 'authorize:task.read:default', + 'audit:allowed:panel.cron.list', + 'confirm', + 'panel-crons:default:1:20:64', + ]); +}); + test('authorizes and audits run.stop before exposing the cancellation body handler', async () => { const { admission, events } = fixture(); const prepared = await admission.prepare( diff --git a/packages/ql3-local-api/test/httpSurface.test.cjs b/packages/ql3-local-api/test/httpSurface.test.cjs index a3404dd7..6ade21fd 100644 --- a/packages/ql3-local-api/test/httpSurface.test.cjs +++ b/packages/ql3-local-api/test/httpSurface.test.cjs @@ -152,6 +152,16 @@ test('serves only the fixed canonical loopback Run route and drains idempotently body: { secrets: [], truncated: false }, }; } + if (value.operation.operationId === 'panel.cron.list') { + return { + statusCode: 200, + body: { + code: 200, + data: { data: [], total: 0 }, + input: value.operation, + }, + }; + } return { statusCode: 200, body: { runs: [], hasMore: false, input: value.operation.input }, @@ -249,6 +259,34 @@ test('serves only the fixed canonical loopback Run route and drains idempotently taskId: 'task_1', }); + const panelCrons = await request( + port, + '/api/crons?searchValue=&page=1&size=20&filters=%7B%7D&t=100', + ); + assert.equal(panelCrons.statusCode, 200); + assert.deepEqual(panelCrons.body, { + code: 200, + data: { data: [], total: 0 }, + input: { + operationId: 'panel.cron.list', + projectId: 'default', + page: 1, + size: 20, + maximumRows: 64, + }, + }); + assert.deepEqual(observed[6].operation, panelCrons.body.input); + + const unsupportedPanelQuery = await request( + port, + '/api/crons?searchValue=private&page=1&size=20&filters=%7B%7D', + ); + assert.deepEqual(unsupportedPanelQuery.body, { + code: 'invalid_panel_cron_list_query', + }); + assert.equal(unsupportedPanelQuery.statusCode, 400); + assert.equal(observed.length, 7); + const cancellationBody = JSON.stringify({ schema: 'qinglong/run-cancellation@v1', mutationId: 'mutation-1', @@ -268,7 +306,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(cancellation.statusCode, 202); assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody)); - assert.deepEqual(observed[6].operation, { + assert.deepEqual(observed[7].operation, { operationId: 'run.cancel', projectId: 'prj_default', runId: 'run_123', @@ -295,7 +333,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(taskStart.statusCode, 202); assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody)); - assert.deepEqual(observed[7].operation, { + assert.deepEqual(observed[8].operation, { operationId: 'task.start', projectId: 'prj_default', taskId: 'task_1', @@ -319,20 +357,20 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(taskPut.statusCode, 202); assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody)); - assert.deepEqual(observed[8].operation, { + assert.deepEqual(observed[9].operation, { operationId: 'task.put', projectId: 'prj_default', taskId: 'task_1', }); - assert.equal(observed[8].localPresence, 'ql3p_request_bound_proof'); - assert.equal(observed[8].taskAuthoringLease, 'ql3a_exact_snapshot_lease'); + assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof'); + assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease'); const log = await request( port, '/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32', ); assert.deepEqual(log.body, { range: { offset: 4, length: 32 } }); - assert.deepEqual(observed[9].operation, { + assert.deepEqual(observed[10].operation, { operationId: 'run.log.read', projectId: 'prj_default', runId: 'run_123', @@ -361,19 +399,19 @@ test('serves only the fixed canonical loopback Run route and drains idempotently }, ); assert.equal(authoring.statusCode, 200); - assert.deepEqual(observed[11].operation, { + assert.deepEqual(observed[12].operation, { operationId: 'task.authoring', projectId: 'prj_default', taskId: 'task_1', }); - assert.equal(observed[11].localPresence, 'ql3p_authoring_read_proof'); + assert.equal(observed[12].localPresence, 'ql3p_authoring_read_proof'); const secrets = await request( port, '/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE', ); assert.deepEqual(secrets.body, { secrets: [], truncated: false }); - assert.deepEqual(observed[12].operation, { + assert.deepEqual(observed[13].operation, { operationId: 'secret.list', projectId: 'prj_default', limit: 8, @@ -397,11 +435,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently ); assert.equal(secretPut.statusCode, 202); assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody)); - assert.deepEqual(observed[13].operation, { + assert.deepEqual(observed[14].operation, { operationId: 'secret.put', projectId: 'prj_default', }); - assert.equal(observed[13].localPresence, 'ql3p_secret_bound_proof'); + assert.equal(observed[14].localPresence, 'ql3p_secret_bound_proof'); for (const invalidPath of [ '/api/v3/projects/prj_default/runs/run_123?expanded=true', @@ -483,7 +521,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently assert.equal(invalid.statusCode, 400); assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' }); } - assert.equal(observed.length, 14); + assert.equal(observed.length, 15); assert.deepEqual( await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]), ['stopped', 'stopped'], diff --git a/packages/ql3-local-api/test/panelCronListRoute.test.cjs b/packages/ql3-local-api/test/panelCronListRoute.test.cjs new file mode 100644 index 00000000..da6d8147 --- /dev/null +++ b/packages/ql3-local-api/test/panelCronListRoute.test.cjs @@ -0,0 +1,245 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + createPanelCronListRoute, +} = require('../dist/panel-compatibility/panelCronListRoute.js'); +const { + createTaskDefinitionRecord, +} = require('@qinglong/runtime-core/task-definition'); +const { + createTriggerRecord, + createBuiltInTriggerSpecSemanticRegistry, +} = require('@qinglong/runtime-core/trigger'); + +function task(taskId, revision = 2, enabled = true) { + return createTaskDefinitionRecord( + { + projectId: 'default', + taskId, + expectedRevision: revision - 1, + mutationId: `019f7300-0000-4000-8000-${String(revision).padStart( + 12, + '0', + )}`, + name: `Task ${taskId}`, + kind: 'command', + spec: { + schema: 'qinglong/command@v1', + config: { command: ['/bin/private'] }, + }, + labels: { private: 'redacted' }, + enabled, + occurredAtMs: 200, + }, + 100, + ); +} + +function trigger(taskRecord, triggerId, enabled = true) { + const semantics = createBuiltInTriggerSpecSemanticRegistry(); + const spec = semantics.normalize({ + projectId: 'default', + triggerId, + taskId: taskRecord.taskId, + taskRevision: taskRecord.revision, + spec: { + schema: 'qinglong/cron@v1', + config: { + expression: '0 * * * *', + timezone: 'UTC', + misfirePolicy: 'skip', + }, + }, + }); + return createTriggerRecord( + { + projectId: 'default', + triggerId, + expectedRevision: null, + mutationId: `019f7300-0000-4000-8001-${ + triggerId.endsWith('b') ? '000000000002' : '000000000001' + }`, + taskId: taskRecord.taskId, + taskRevision: taskRecord.revision, + taskContentDigest: taskRecord.contentDigest, + spec, + enabled, + occurredAtMs: 400, + }, + 300, + ); +} + +test('projects one bounded page of pinned QL3 cron triggers into the legacy panel envelope', async () => { + const taskA = task('task-a'); + const taskB = task('task-b', 3, false); + const triggers = [ + trigger(taskA, 'cron:task-a'), + trigger(taskB, 'cron:task-b'), + ]; + const calls = []; + const route = createPanelCronListRoute({ + tasks: { + async findTaskDefinitionRevision(projectId, taskId, revision) { + calls.push(['task', projectId, taskId, revision]); + return [taskA, taskB].find( + (entry) => entry.taskId === taskId && entry.revision === revision, + ); + }, + }, + triggers: { + async listTriggers(input) { + calls.push(['trigger', input]); + return { + triggers: triggers.slice(0, input.limit), + truncated: triggers.length > input.limit, + ...(triggers.length > input.limit + ? { next: { triggerId: triggers[input.limit - 1].triggerId } } + : {}), + }; + }, + }, + }); + + const first = await route.handle({ + projectId: 'default', + page: 1, + size: 1, + maximumRows: 64, + }); + assert.equal(first.statusCode, 200); + assert.equal(first.body.code, 200); + assert.equal(first.body.data.total, 2); + assert.deepEqual(first.body.data.data, [ + { + id: 'cron:task-a', + name: 'Task task-a', + command: 'ql3:command:task-a@2', + schedule: '0 * * * *', + extra_schedules: [], + status: 1, + isDisabled: 0, + isPinned: 0, + createdAt: new Date(300).toISOString(), + updatedAt: new Date(400).toISOString(), + ql3: { + projectId: 'default', + taskId: 'task-a', + taskRevision: 2, + triggerId: 'cron:task-a', + triggerRevision: 1, + timezone: 'UTC', + misfirePolicy: 'skip', + readOnly: true, + }, + }, + ]); + + const second = await route.handle({ + projectId: 'default', + page: 2, + size: 1, + maximumRows: 64, + }); + assert.equal(second.body.data.total, 2); + assert.equal(second.body.data.data[0].id, 'cron:task-b'); + assert.equal(second.body.data.data[0].status, 2); + assert.equal(second.body.data.data[0].isDisabled, 1); + assert.deepEqual(calls[0], ['trigger', { projectId: 'default', limit: 1 }]); + assert.deepEqual(calls[2], ['trigger', { projectId: 'default', limit: 2 }]); + + const afterEnd = await route.handle({ + projectId: 'default', + page: 3, + size: 1, + maximumRows: 64, + }); + assert.deepEqual(afterEnd.body.data, { data: [], total: 2 }); +}); + +test('fails closed for detached pins, unsupported triggers, invalid budgets and unavailable storage', async () => { + const pinned = task('task-a'); + const cron = trigger(pinned, 'cron:task-a'); + for (const sources of [ + { + tasks: { + async findTaskDefinitionRevision() { + return null; + }, + }, + triggers: { + async listTriggers() { + return { triggers: [cron], truncated: false }; + }, + }, + }, + { + tasks: { + async findTaskDefinitionRevision() { + return pinned; + }, + }, + triggers: { + async listTriggers() { + return { + triggers: [ + { ...cron, spec: { schema: 'vendor/event@v1', config: {} } }, + ], + truncated: false, + }; + }, + }, + }, + { + tasks: { + async findTaskDefinitionRevision() { + return pinned; + }, + }, + triggers: { + async listTriggers() { + throw new Error('offline'); + }, + }, + }, + ]) { + const route = createPanelCronListRoute(sources); + assert.deepEqual( + await route.handle({ + projectId: 'default', + page: 1, + size: 1, + maximumRows: 64, + }), + { + statusCode: 503, + body: { code: 503, message: 'QL3 面板兼容视图暂不可用' }, + }, + ); + } + const route = createPanelCronListRoute({ + tasks: { + async findTaskDefinitionRevision() { + return pinned; + }, + }, + triggers: { + async listTriggers() { + return { triggers: [], truncated: false }; + }, + }, + }); + assert.equal( + ( + await route.handle({ + projectId: 'default', + page: 5, + size: 20, + maximumRows: 64, + }) + ).statusCode, + 400, + ); + assert.throws(() => createPanelCronListRoute({}), TypeError); +}); diff --git a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs index e372d210..e87a8a10 100644 --- a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs +++ b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs @@ -836,6 +836,46 @@ test('serves an authenticated Run through one real SQLite authority and durable assert.equal(triggerList.body.triggers[0].triggerId, 'cron:task-1'); assert.equal(triggerList.body.triggers[0].spec, undefined); + const panelCrons = await request( + port, + `Bearer ${TOKEN}`, + '/api/crons?searchValue=&page=1&size=20&filters=%7B%7D', + ); + assert.equal(panelCrons.statusCode, 200, JSON.stringify(panelCrons)); + assert.equal(panelCrons.body.code, 200); + assert.equal(panelCrons.body.data.total, 1); + assert.equal(panelCrons.body.data.data.length, 1); + assert.deepEqual( + { + id: panelCrons.body.data.data[0].id, + name: panelCrons.body.data.data[0].name, + command: panelCrons.body.data.data[0].command, + schedule: panelCrons.body.data.data[0].schedule, + status: panelCrons.body.data.data[0].status, + isDisabled: panelCrons.body.data.data[0].isDisabled, + ql3: panelCrons.body.data.data[0].ql3, + }, + { + id: 'cron:task-1', + name: 'Local API Task updated', + command: 'ql3:command:task-1@2', + schedule: '0 * * * *', + status: 1, + isDisabled: 0, + ql3: { + projectId: 'default', + taskId: 'task-1', + taskRevision: 2, + triggerId: 'cron:task-1', + triggerRevision: 1, + timezone: 'UTC', + misfirePolicy: 'skip', + readOnly: true, + }, + }, + ); + assert.equal(JSON.stringify(panelCrons).includes('/bin/echo'), false); + const triggerRead = await request(port, `Bearer ${TOKEN}`, triggerPath); assert.equal(triggerRead.statusCode, 200); assert.deepEqual(triggerRead.body.trigger.spec, { diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 0218b0b3..38be6049 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localApi.rootSourceFileRoles, }, { - sourceFiles: 27, + sourceFiles: 28, rootSourceFiles: 1, rootSourceLines: 71, - nestedSourceFiles: 26, + nestedSourceFiles: 27, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );