mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add bounded legacy panel cron adapter
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# QingLong 3.0 Architecture RFC
|
# 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:<kind>:<taskId>@<revision>` 描述符,不暴露 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-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,不能零改直连。
|
- 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,不能零改直连。
|
||||||
|
|||||||
@@ -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:<kind>:<taskId>@<revision>` 描述符,不返回 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 目标。
|
||||||
@@ -532,6 +532,7 @@
|
|||||||
| [ADR-0526](./ADR-0526-exact-post-write-reconciliation-capture.md) | Exact 写后 Reconciliation Capture | Accepted(同源 exact Console 双架构阶段实物已交付) |
|
| [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-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-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) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
LocalApiSecretListRoute,
|
LocalApiSecretListRoute,
|
||||||
LocalApiSecretPutRoute,
|
LocalApiSecretPutRoute,
|
||||||
} from '../secret/secretRoutes';
|
} from '../secret/secretRoutes';
|
||||||
|
import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||||
import type { LocalApiResponse } from '../transport/contract';
|
import type { LocalApiResponse } from '../transport/contract';
|
||||||
|
|
||||||
export type LocalApiAdmissionOperation =
|
export type LocalApiAdmissionOperation =
|
||||||
@@ -125,6 +126,13 @@ export type LocalApiAdmissionOperation =
|
|||||||
| Readonly<{
|
| Readonly<{
|
||||||
operationId: 'secret.put';
|
operationId: 'secret.put';
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
}>
|
||||||
|
| Readonly<{
|
||||||
|
operationId: 'panel.cron.list';
|
||||||
|
projectId: string;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
maximumRows: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface LocalApiAdmissionRequest {
|
export interface LocalApiAdmissionRequest {
|
||||||
@@ -168,6 +176,7 @@ export interface LocalApiAdmissionOptions {
|
|||||||
readonly triggerPutRoute: LocalApiTriggerPutRoute;
|
readonly triggerPutRoute: LocalApiTriggerPutRoute;
|
||||||
readonly secretListRoute: LocalApiSecretListRoute;
|
readonly secretListRoute: LocalApiSecretListRoute;
|
||||||
readonly secretPutRoute: LocalApiSecretPutRoute;
|
readonly secretPutRoute: LocalApiSecretPutRoute;
|
||||||
|
readonly panelCronListRoute: PanelCronListRoute;
|
||||||
readonly now?: () => number;
|
readonly now?: () => number;
|
||||||
readonly randomUuid?: () => string;
|
readonly randomUuid?: () => string;
|
||||||
}
|
}
|
||||||
@@ -253,6 +262,7 @@ export function createLocalApiAdmission(
|
|||||||
typeof options.triggerPutRoute?.handle !== 'function' ||
|
typeof options.triggerPutRoute?.handle !== 'function' ||
|
||||||
typeof options.secretListRoute?.handle !== 'function' ||
|
typeof options.secretListRoute?.handle !== 'function' ||
|
||||||
typeof options.secretPutRoute?.handle !== 'function' ||
|
typeof options.secretPutRoute?.handle !== 'function' ||
|
||||||
|
typeof options.panelCronListRoute?.handle !== 'function' ||
|
||||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||||
(options.randomUuid !== undefined &&
|
(options.randomUuid !== undefined &&
|
||||||
typeof options.randomUuid !== 'function')
|
typeof options.randomUuid !== 'function')
|
||||||
@@ -393,7 +403,8 @@ export function createLocalApiAdmission(
|
|||||||
: request.operation.operationId === 'task.list' ||
|
: request.operation.operationId === 'task.list' ||
|
||||||
request.operation.operationId === 'task.get' ||
|
request.operation.operationId === 'task.get' ||
|
||||||
request.operation.operationId === 'trigger.list' ||
|
request.operation.operationId === 'trigger.list' ||
|
||||||
request.operation.operationId === 'trigger.get'
|
request.operation.operationId === 'trigger.get' ||
|
||||||
|
request.operation.operationId === 'panel.cron.list'
|
||||||
? 'task.read'
|
? 'task.read'
|
||||||
: request.operation.operationId === 'secret.list'
|
: request.operation.operationId === 'secret.list'
|
||||||
? 'secret.manage'
|
? 'secret.manage'
|
||||||
@@ -553,6 +564,14 @@ export function createLocalApiAdmission(
|
|||||||
? { after: request.operation.after }
|
? { 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.put':
|
||||||
case 'task.authoring':
|
case 'task.authoring':
|
||||||
case 'trigger.put':
|
case 'trigger.put':
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
createLocalApiSecretListRoute,
|
createLocalApiSecretListRoute,
|
||||||
createLocalApiSecretPutRoute,
|
createLocalApiSecretPutRoute,
|
||||||
} from '../secret/secretRoutes';
|
} from '../secret/secretRoutes';
|
||||||
|
import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||||
|
|
||||||
export interface LocalApiProductSurfaceEvent {
|
export interface LocalApiProductSurfaceEvent {
|
||||||
@@ -221,6 +222,10 @@ export function createLocalApiProductSurface(
|
|||||||
? {}
|
? {}
|
||||||
: { randomUuid: options.randomUuid }),
|
: { randomUuid: options.randomUuid }),
|
||||||
});
|
});
|
||||||
|
const panelCronListRoute = createPanelCronListRoute({
|
||||||
|
tasks: authority.taskDefinitions,
|
||||||
|
triggers: authority.triggers,
|
||||||
|
});
|
||||||
const admission = createLocalApiAdmission({
|
const admission = createLocalApiAdmission({
|
||||||
authenticator,
|
authenticator,
|
||||||
policy,
|
policy,
|
||||||
@@ -241,6 +246,7 @@ export function createLocalApiProductSurface(
|
|||||||
triggerPutRoute,
|
triggerPutRoute,
|
||||||
secretListRoute,
|
secretListRoute,
|
||||||
secretPutRoute,
|
secretPutRoute,
|
||||||
|
panelCronListRoute,
|
||||||
...(options.now === undefined ? {} : { now: options.now }),
|
...(options.now === undefined ? {} : { now: options.now }),
|
||||||
...(options.randomUuid === undefined
|
...(options.randomUuid === undefined
|
||||||
? {}
|
? {}
|
||||||
|
|||||||
@@ -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<PanelCronListRequest>): Promise<LocalApiResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelCronListSources {
|
||||||
|
readonly tasks: Pick<TaskDefinitionSource, 'findTaskDefinitionRevision'>;
|
||||||
|
readonly triggers: Pick<TriggerSource, 'listTriggers'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(
|
||||||
|
statusCode: number,
|
||||||
|
body: Readonly<Record<string, unknown>>,
|
||||||
|
): LocalApiResponse {
|
||||||
|
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRequest(request: Readonly<PanelCronListRequest>): 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<PanelCronListSources>,
|
||||||
|
): Readonly<PanelCronListRoute> {
|
||||||
|
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<PanelCronListRequest>) {
|
||||||
|
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<string, unknown>[] = [];
|
||||||
|
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 面板兼容视图暂不可用',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -64,7 +64,8 @@ type LocalApiRouteResolution =
|
|||||||
| 'invalid_run_log_read_query'
|
| 'invalid_run_log_read_query'
|
||||||
| 'invalid_task_list_query'
|
| 'invalid_task_list_query'
|
||||||
| 'invalid_trigger_list_query'
|
| 'invalid_trigger_list_query'
|
||||||
| 'invalid_secret_list_query';
|
| 'invalid_secret_list_query'
|
||||||
|
| 'invalid_panel_cron_list_query';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface LocalApiHttpSurfaceOptions {
|
export interface LocalApiHttpSurfaceOptions {
|
||||||
@@ -549,6 +550,62 @@ function parseRunAttemptLogReadQuery(
|
|||||||
return Object.freeze({ offset, length });
|
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(
|
function route(
|
||||||
request: IncomingMessage,
|
request: IncomingMessage,
|
||||||
profile: LocalApplicationProfile,
|
profile: LocalApplicationProfile,
|
||||||
@@ -558,11 +615,15 @@ function route(
|
|||||||
typeof rawUrl !== 'string' ||
|
typeof rawUrl !== 'string' ||
|
||||||
rawUrl.length < 1 ||
|
rawUrl.length < 1 ||
|
||||||
Buffer.byteLength(rawUrl, 'utf8') > MAX_URL_BYTES ||
|
Buffer.byteLength(rawUrl, 'utf8') > MAX_URL_BYTES ||
|
||||||
rawUrl.includes('%') ||
|
|
||||||
rawUrl.includes('#')
|
rawUrl.includes('#')
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
const panelCronList = parsePanelCronListRoute(rawUrl, profile);
|
||||||
|
if (panelCronList) return panelCronList;
|
||||||
|
}
|
||||||
|
if (rawUrl.includes('%')) return null;
|
||||||
const separator = rawUrl.indexOf('?');
|
const separator = rawUrl.indexOf('?');
|
||||||
if (separator !== rawUrl.lastIndexOf('?')) return null;
|
if (separator !== rawUrl.lastIndexOf('?')) return null;
|
||||||
const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator);
|
const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator);
|
||||||
|
|||||||
@@ -303,6 +303,17 @@ function fixture(overrides = {}) {
|
|||||||
return { statusCode: 201, body: { status: 'inserted' } };
|
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,
|
now: () => 10_000,
|
||||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||||
...overrides,
|
...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 () => {
|
test('authorizes and audits run.stop before exposing the cancellation body handler', async () => {
|
||||||
const { admission, events } = fixture();
|
const { admission, events } = fixture();
|
||||||
const prepared = await admission.prepare(
|
const prepared = await admission.prepare(
|
||||||
|
|||||||
@@ -152,6 +152,16 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
|||||||
body: { secrets: [], truncated: false },
|
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 {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
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',
|
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({
|
const cancellationBody = JSON.stringify({
|
||||||
schema: 'qinglong/run-cancellation@v1',
|
schema: 'qinglong/run-cancellation@v1',
|
||||||
mutationId: 'mutation-1',
|
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.equal(cancellation.statusCode, 202);
|
||||||
assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
|
assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
|
||||||
assert.deepEqual(observed[6].operation, {
|
assert.deepEqual(observed[7].operation, {
|
||||||
operationId: 'run.cancel',
|
operationId: 'run.cancel',
|
||||||
projectId: 'prj_default',
|
projectId: 'prj_default',
|
||||||
runId: 'run_123',
|
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.equal(taskStart.statusCode, 202);
|
||||||
assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
|
assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
|
||||||
assert.deepEqual(observed[7].operation, {
|
assert.deepEqual(observed[8].operation, {
|
||||||
operationId: 'task.start',
|
operationId: 'task.start',
|
||||||
projectId: 'prj_default',
|
projectId: 'prj_default',
|
||||||
taskId: 'task_1',
|
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.equal(taskPut.statusCode, 202);
|
||||||
assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
|
assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
|
||||||
assert.deepEqual(observed[8].operation, {
|
assert.deepEqual(observed[9].operation, {
|
||||||
operationId: 'task.put',
|
operationId: 'task.put',
|
||||||
projectId: 'prj_default',
|
projectId: 'prj_default',
|
||||||
taskId: 'task_1',
|
taskId: 'task_1',
|
||||||
});
|
});
|
||||||
assert.equal(observed[8].localPresence, 'ql3p_request_bound_proof');
|
assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof');
|
||||||
assert.equal(observed[8].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||||
|
|
||||||
const log = await request(
|
const log = await request(
|
||||||
port,
|
port,
|
||||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32',
|
'/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(log.body, { range: { offset: 4, length: 32 } });
|
||||||
assert.deepEqual(observed[9].operation, {
|
assert.deepEqual(observed[10].operation, {
|
||||||
operationId: 'run.log.read',
|
operationId: 'run.log.read',
|
||||||
projectId: 'prj_default',
|
projectId: 'prj_default',
|
||||||
runId: 'run_123',
|
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.equal(authoring.statusCode, 200);
|
||||||
assert.deepEqual(observed[11].operation, {
|
assert.deepEqual(observed[12].operation, {
|
||||||
operationId: 'task.authoring',
|
operationId: 'task.authoring',
|
||||||
projectId: 'prj_default',
|
projectId: 'prj_default',
|
||||||
taskId: 'task_1',
|
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(
|
const secrets = await request(
|
||||||
port,
|
port,
|
||||||
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
|
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
|
||||||
);
|
);
|
||||||
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
|
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
|
||||||
assert.deepEqual(observed[12].operation, {
|
assert.deepEqual(observed[13].operation, {
|
||||||
operationId: 'secret.list',
|
operationId: 'secret.list',
|
||||||
projectId: 'prj_default',
|
projectId: 'prj_default',
|
||||||
limit: 8,
|
limit: 8,
|
||||||
@@ -397,11 +435,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
|||||||
);
|
);
|
||||||
assert.equal(secretPut.statusCode, 202);
|
assert.equal(secretPut.statusCode, 202);
|
||||||
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
|
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
|
||||||
assert.deepEqual(observed[13].operation, {
|
assert.deepEqual(observed[14].operation, {
|
||||||
operationId: 'secret.put',
|
operationId: 'secret.put',
|
||||||
projectId: 'prj_default',
|
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 [
|
for (const invalidPath of [
|
||||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
'/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.equal(invalid.statusCode, 400);
|
||||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||||
}
|
}
|
||||||
assert.equal(observed.length, 14);
|
assert.equal(observed.length, 15);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||||
['stopped', 'stopped'],
|
['stopped', 'stopped'],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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].triggerId, 'cron:task-1');
|
||||||
assert.equal(triggerList.body.triggers[0].spec, undefined);
|
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);
|
const triggerRead = await request(port, `Bearer ${TOKEN}`, triggerPath);
|
||||||
assert.equal(triggerRead.statusCode, 200);
|
assert.equal(triggerRead.statusCode, 200);
|
||||||
assert.deepEqual(triggerRead.body.trigger.spec, {
|
assert.deepEqual(triggerRead.body.trigger.spec, {
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 27,
|
sourceFiles: 28,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 71,
|
rootSourceLines: 71,
|
||||||
nestedSourceFiles: 26,
|
nestedSourceFiles: 27,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user