mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
feat(ql3): prove first usable automation journey
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
|
||||
最新增量证据(2026-08-28):
|
||||
|
||||
- D-419/ADR-0514(实现中):二十天研发的阶段产物验收从“能下载、能启动、能打开 Console”推进到“能完成第一份真实自动化”。首 Owner bootstrap ready record 不再要求部署者手工拼接 token;短生命周期 operator 新增独立、no-replace、可幂等重放且不返回 Secret 的 `owner.credential-presentation.install-from-delivery`,在同 UID、`0700/0600`、deployment-root containment、no-symlink 和原子发布边界内生成标准 credential presentation,同时不修改 HIGH 风险的 delivery record/recovery/acknowledgement 协议。headless 与 Console 都获得后续管理所需的 presentation,但只有 opt-in Console quickstart 创建默认不自动运行、无网络/SecretRef/Trigger 的 `alpha-first-automation`;用户仍需核对 revision/content digest fence 后显式启动。原生 amd64/arm64 Console 门必须使用该真实 Owner credential 读取 Task、接受一次 fenced Run、等待 `succeeded` 并从 bounded log 看到固定工作标记。Trial Kit/verification 升为 `@v5/@v3`;默认低配 headless 不新增示例数据、listener、daemon、timer、依赖或稳态 RSS,Cluster 不复用本机 SQLite credential 路径。首份真实双架构 v5 大产物仍待维护者显式授权。
|
||||
|
||||
- D-418/ADR-0513(已实现,首份真实双架构产物待授权):二十天研发形成的 headless 用户旅程与 opt-in Console 已闭合为部署者可选择的 Local Alpha 交付链。手动门新增默认 `headless` 的 `local_alpha_variant=headless|console`;一次授权 run 只生成所选变体,不把两套 Application 塞入同一 archive。默认 Docker/runtime 与路由/NAS 继续使用原 8 个内部 package、无 listener 的 headless 镜像;独立 `runtime-console` target 增加 Local API/Console,固定 12-package、6 MiB/640-file、AI-excluded 闭包。Console quickstart 仅支持 Linux Docker host,使用 host network 让容器内仍严格绑定 `127.0.0.1:5700` 的服务可由宿主访问,远程只能显式 SSH tunnel,不放宽为 `0.0.0.0` 或 public ingress。Trial Kit、verification、Local milestone 与 stage index 分别升级为 `qinglong/alpha-local-trial-kit@v4`、`qinglong/alpha-local-trial-kit-verification@v2`、`qinglong/alpha-local-milestone@v2`、`qinglong/alpha-stage-index@v2`,variant 贯穿 archive/SBOM/evidence/artifact name/deployment selection,拒绝跨变体与跨架构混用。普通双架构 CI 同时构建、Trivy 扫描、库存/SBOM 对账和实跑两种镜像;Console 必须完成 fresh Owner、loopback 首页 200、未认证 API 401 与 graceful stop。实际大 archive 仍只由维护者显式 `produce_alpha_artifacts=true` 生成;没有同 run/attempt 的双架构 milestone 时,单个 archive 仍只是工程中间物,不冒充 public release、生产 HA、签名或 LTS。
|
||||
|
||||
- D-417/ADR-0512(已实现,部署交付由 D-418 闭合):开发约二十天后的阶段产物从“可直接启动的 headless runtime”继续推进到首个可操作 Web 产品面。既有可选 `@qinglong/local-api` 现在内置 `/`、`/console.css`、`/console.js` 三文件离线 Console,总计 43,252 bytes,无前端框架、CDN、网络字体、遥测或 Web Storage;它复用同一 Node 进程、loopback listener、SQLite authority、Bearer/Project Policy/durable audit/re-confirm 链,不新增 package、sidecar、数据库连接、timer、watcher 或 cache。部署者可以按 Project 查看 Task revision/content fence、显式确认启动,查看 durable Run、Workflow Step 数与 Event sequence,并对未终止 Run 提交显式 cancellation request;取消请求不会伪装成停止终态。Credential 只在当前页面内存,服务端固定 CSP/COOP/CORP/Permissions Policy/no-referrer/nosniff/DENY/no-store,远程设备只能由操作者建立 SSH tunnel,不开放 LAN/public listener。默认 Edge/Standalone 与 D-416 Trial Kit 继续 headless、零增量;Console 只进入 opt-in `edge-application-api|standalone-application-api`,两档 artifact 为 `3,953,346 / 3,953,490 bytes`、467 files、12 packages、90 loaded modules,低于 6 MiB/640-file 门。Local API 48/48、pruner 11/11、完整 backend `1,637 total / 1,635 pass / 2 conditional skip / 0 fail`、18-package clean build/test 退出 0;package boundary 保持 18 packages 且无 single-source/shallow package,Cluster dependency、122-module Edge import 与全部 14 档 Local artifact audit 均 compatible。桌面 1440×960 与手机 390×844 已检查 Task、Run/Event spine 和显式确认工作态。D-417 当时只形成 source/application-api 制品;其 Console image/quickstart 下载交付已由 D-418 完成,固定低性能物理 Edge 容量数据和 public release 仍是后续门。
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-0514:阶段可用的首个自动化旅程
|
||||
|
||||
- 状态:Accepted(首份真实双架构 v5 Trial Kit 待维护者授权)
|
||||
- 日期:2026-08-28
|
||||
- 对应 RFC 切片:D-419
|
||||
|
||||
## 背景
|
||||
|
||||
QingLong 3.0 已连续开发约二十天。D-416~D-418 已把 Local Alpha 从可构建镜像推进到可选择的 headless/Console Trial Kit:下载者可以离线校验、完成 fresh setup、建立首 Owner、启动受资源限制的 Application,并在 Console 中读取和启动既有 Task。主 CI 也已经在原生 amd64/arm64 上验证这些路径。
|
||||
|
||||
但 fresh 数据库没有 Task。更关键的是,Owner bootstrap ceremony 交付的 ready record 包含 claim 所需的完整记录,它不是 `ql3-task` 和 Web Console 接受的 credential presentation。让用户手工从 JSON 拼接 token,或让 Web Bearer 请求直接获得 strong 管理权限,都会把工程能力误报为阶段可用产品,并破坏既有认证边界。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 增加独立、短生命周期的 credential presentation installer
|
||||
|
||||
`ql3 owner` 新增 `owner.credential-presentation.install-from-delivery` command-file operation。它只把一个已交付的 Owner credential ready record 转换为标准 `qinglong3-local-identity-credential-presentation` 文件:
|
||||
|
||||
- source、destination 必须位于同一个 canonical deployment root;
|
||||
- root、父目录、source 和既有 destination 必须保持同 UID、`0700/0600`、非 symlink;
|
||||
- destination 以临时文件、`fsync`、no-replace hard link 和目录 `fsync` 原子发布;
|
||||
- 只允许首次 `installed`,或内容完全相同的 `existing` 重放;冲突时失败关闭;
|
||||
- command 结果不返回 token、Secret、绝对路径或 delivery 内容。
|
||||
|
||||
该 installer 是新模块,不改变 `SecretDeliveryPrivateFilesystemStore` 的 record、recovery、acknowledgement 或 GC 语义。
|
||||
|
||||
### 2. quickstart 交付可直接使用的 Owner presentation
|
||||
|
||||
headless 和 Console quickstart 都在首 Owner claim 后调用上述 operation,生成数据根内的 `owner-credential.json`。这使后续受支持的 `ql3 task|trigger|identity|policy|...` 短生命周期命令不再依赖部署者手工拼接 Secret。
|
||||
|
||||
bootstrap delivery 仍保留到操作者完成消费确认;installer 不把“复制成功”冒充 delivery acknowledgement。
|
||||
|
||||
### 3. Console Trial Kit 创建一个有界示例 Task
|
||||
|
||||
只有显式选择 `console` variant 时,quickstart 才通过 strong local operator 创建 `alpha-first-automation`:
|
||||
|
||||
- immutable `qinglong/command@v1`;
|
||||
- 只执行 Application image 内的 `/bin/echo`;
|
||||
- 无网络、无 SecretRef、无定时 Trigger,默认不自动运行;
|
||||
- 用户仍需在 Console 核对 revision/content digest 并显式启动。
|
||||
|
||||
默认 headless 路由/NAS 不创建示例 Task,也不增加 listener、常驻进程、timer、连接池或稳态 RSS;它只获得后续管理需要的 credential presentation。Cluster/Kubernetes 不复用此 SQLite/本机 credential 路径。
|
||||
|
||||
### 4. 原生门必须证明真实工作完成
|
||||
|
||||
Console live journey 不再只检查首页 200 和未认证 API 401。每个原生架构还必须:
|
||||
|
||||
1. 通过 operator 安装 Owner presentation;
|
||||
2. 通过 operator 创建示例 Task;
|
||||
3. 使用该 credential 从 loopback API 读取 Task;
|
||||
4. 使用 revision/content digest fence 显式启动一次 Run;
|
||||
5. 等待 `succeeded` 终态,并从 bounded Run log 看到固定工作标记;
|
||||
6. 最后完成 graceful stop 和 SQLite integrity 检查。
|
||||
|
||||
Trial Kit 升级为 `qinglong/alpha-local-trial-kit@v5`(manifest schemaVersion 6),verification evidence 升级为 `qinglong/alpha-local-trial-kit-verification@v3`,新增 `ownerCredentialPresentation` 与 variant-aware `firstAutomationJourney` gate。Local milestone 与 stage index 文件形状不变,继续使用各自 v2 schema,但只能收录通过当前严格 auditor 的 bundle。
|
||||
|
||||
## 不采用的方案
|
||||
|
||||
- 不在 Web API 增加 Task create:HTTP Bearer principal 是 `single_factor`,不能伪装成 `local_console` strong assurance。
|
||||
- 不依赖宿主 `jq`、Node.js 或 shell 字符串拼接 Secret:Trial Kit 的宿主前置仍只有 POSIX shell、`sha256sum` 和 Docker。
|
||||
- 不修改 bootstrap ready record 为 presentation:claim、recovery 和 acknowledgement 已有独立安全语义,不能为 UI 便利改写。
|
||||
- 不把示例 Task 放进 headless:低配设备默认档保持最小、无操作面、无示例数据。
|
||||
|
||||
## 结果与剩余边界
|
||||
|
||||
D-419 形成“下载、验真、启动、认证、执行、观察终态”的首个阶段可用业务闭环。它仍是 fresh Alpha,不是 2.x 升级、生产 HA、公开 release、签名或 LTS。实际大 archive 继续只允许维护者显式设置 `produce_alpha_artifacts=true`;普通 push 的绿色 CI 证明源码可生成和实跑,不自动产生可下载大产物。
|
||||
@@ -23,6 +23,8 @@
|
||||
| --- | --- | --- |
|
||||
| D-418 headless Trial Kit v4 | 默认低配变体;POSIX shell + Docker 一条命令完成 checksum、load、fresh setup、首 Owner、Application active/stop;无 listener | 首份远端双架构实际 artifact 仍需维护者授权 `produce_alpha_artifacts=true` |
|
||||
| D-418 Console Trial Kit v4 | 显式 Linux-only 变体;同一 quickstart 关闭 Owner ceremony 后启动 loopback Console,支持 Task/Run/Event、显式运行与取消;CI 验证首页 200、未认证 API 401 | 仍是 Alpha、无 public ingress/TLS/签名;首份实际 archive 同样需维护者授权 |
|
||||
| D-419 Local Trial Kit v5 | headless/Console 都原子安装标准 Owner credential presentation;不再要求部署者手工拼接 bootstrap Secret | 首份实际双架构 v5 archive 仍需维护者授权 |
|
||||
| D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 |
|
||||
|
||||
D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。
|
||||
|
||||
@@ -52,7 +54,7 @@ ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verif
|
||||
Local artifact 含:
|
||||
|
||||
- 一个包含所选 Application 与短生命周期 operator 的 archive;headless 为 `qinglong3-local-trial-kit-<arch>.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-<arch>.docker.tar`,共享 Node 基础层在 archive 中去重;
|
||||
- schema 为 `qinglong/alpha-local-trial-kit@v4` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256;
|
||||
- schema 为 `qinglong/alpha-local-trial-kit@v5` 的 `manifest.json`,通过 `variant/archive/images/sboms/quickstart/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256;
|
||||
- canonical `quickstart.sh`,在目标 Linux 设备上只依赖 POSIX shell、`sha256sum` 和 Docker,完成 checksum、load、identity、fresh Owner 与 Profile-bound Application active;
|
||||
- `verification-evidence.json` 绑定 `workflow_dispatch` 的 workflow ref/SHA、run ID/attempt、同架构两个 exact image ID 和完整 gate 集;下载者仍须到 GitHub 交叉检查 run,它不替代正式签名;
|
||||
- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM;
|
||||
|
||||
@@ -25,7 +25,7 @@ sha256sum --check SHA256SUMS
|
||||
|
||||
`manifest.json` 必须满足:
|
||||
|
||||
- `schema` 为 `qinglong/alpha-local-trial-kit@v4`;
|
||||
- `schema` 为 `qinglong/alpha-local-trial-kit@v5`;
|
||||
- `variant` 为 `headless` 或 `console`,并与 milestone、application SBOM 和 artifact 名一致;
|
||||
- `sourceRevision` 是你准备试用的完整 40 位 commit;
|
||||
- `architecture` 与主机相同;
|
||||
@@ -45,7 +45,7 @@ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \
|
||||
|
||||
## 一条命令完成 Fresh 试运行
|
||||
|
||||
v4 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX
|
||||
v5 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX
|
||||
shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全
|
||||
隔离的绝对路径:
|
||||
|
||||
@@ -60,8 +60,9 @@ sh quickstart.sh standalone /srv/qinglong3-alpha-data ql3-alpha-standalone
|
||||
```
|
||||
|
||||
脚本会自动执行全包 checksum、加载 archive、核对 exact image ID/source/architecture、
|
||||
以当前 UID:GID 和无网络的短生命周期 operator 完成 fresh setup 与首 Owner 建立,随后按
|
||||
Profile 资源上限启动 Application。只有容器日志出现结构化 `active` 事件才返回成功。
|
||||
以当前 UID:GID 和无网络的短生命周期 operator 完成 fresh setup、首 Owner 建立与标准
|
||||
`owner-credential.json` presentation 安装,随后按 Profile 资源上限启动 Application。只有
|
||||
容器日志出现结构化 `active` 事件才返回成功。
|
||||
Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command 结果保留在
|
||||
`results/`;两者都位于 `0700` 私有根内,不会打印 Secret 到终端。
|
||||
|
||||
@@ -76,6 +77,11 @@ AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内
|
||||
不是 2.x Web UI 的生产替代版本。Console 能力和凭据边界见
|
||||
[Local Web Console](./ql3-local-web-console.md)。
|
||||
|
||||
Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的
|
||||
`alpha-first-automation`。在页面输入 `owner-credential.json` 中的 token,选择该 Task,
|
||||
核对 revision/content digest 后显式运行;它只执行 `/bin/echo` 固定标记,不使用网络、
|
||||
SecretRef 或 Trigger。headless 不创建示例 Task,因此低配默认档没有示例数据或稳态开销。
|
||||
|
||||
## 手工加载与最小 smoke
|
||||
|
||||
从 `manifest.json.archive.file` 找到 archive 后加载:
|
||||
@@ -103,7 +109,7 @@ docker run --rm --read-only --network none --cap-drop ALL \
|
||||
|
||||
## Fresh 试运行边界
|
||||
|
||||
完整 fresh setup、首 Owner ceremony、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。Console 还必须证明首页返回 200、未认证 API 返回 401。v4 artifact job 必须从将要上传的目录实际执行 `quickstart.sh` 并完成 graceful stop。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。
|
||||
完整 fresh setup、首 Owner ceremony、Owner presentation 安装、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。Console 还必须证明首页返回 200、未认证 API 返回 401,并用真实 Owner credential 完成 Task read、fenced start、`succeeded` 终态与 bounded log marker。v5 artifact job 必须从将要上传的目录实际执行 `quickstart.sh` 并完成 graceful stop。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。
|
||||
|
||||
Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PID;Standalone 为 256 MiB、0.5 CPU、256 PID;operator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。
|
||||
|
||||
|
||||
@@ -59,7 +59,33 @@ ql3-local-setup run --command-file /opt/qinglong3/setup.json
|
||||
输出 key、digest 或路径。
|
||||
|
||||
setup 只准备存储和密钥 authority。随后使用 `ql3-owner` 完成 Identity provision、
|
||||
challenge、Owner claim 与 delivery acknowledgement。
|
||||
challenge 与 Owner claim。在提交 credential delivery acknowledgement 前,先把 ready record
|
||||
安装为后续管理命令和 Console 可直接使用的标准 presentation:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"operation": "owner.credential-presentation.install-from-delivery",
|
||||
"options": {
|
||||
"deploymentRoot": "/opt/qinglong3",
|
||||
"databasePath": "/opt/qinglong3/qinglong3.sqlite",
|
||||
"pepperPath": "/opt/qinglong3/owner-peppers/b3duZXItdjE.pepper",
|
||||
"pepperKeyId": "owner-v1",
|
||||
"secretDeliveryDirectory": "/opt/qinglong3/owner-delivery",
|
||||
"profile": "edge",
|
||||
"busyTimeoutMs": 100
|
||||
},
|
||||
"request": {
|
||||
"credentialMutationId": "REPLACE_WITH_PROVISION_MUTATION_UUID",
|
||||
"destinationFilePath": "/opt/qinglong3/owner-credential.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
该 command file 与 destination 都必须是 `0600`;首次返回 `installed`,原样重放返回
|
||||
`existing`。结果不会返回 token 或路径。确认 consumer 已能用
|
||||
`owner-credential.json` 完成真实认证后,才执行 credential/challenge delivery
|
||||
acknowledgement;不要先清理唯一 ready record。
|
||||
|
||||
## 3. 创建 fresh application 配置
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QingLong 3.0 Local Web Console
|
||||
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它不是当前 headless Local Alpha Trial Kit 的组成部分,也不是 2.x Web UI 的完整替代品。
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
|
||||
|
||||
## 选择部署档位
|
||||
|
||||
@@ -11,7 +11,7 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查
|
||||
| 普通单节点服务器 | 选择 `standalone-application-api` |
|
||||
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
|
||||
|
||||
当前仓库能生成 Node.js `application-api` 制品;D-416 的 v3 Docker Trial Kit 仍只包含 headless Application。需要下载即用的 Console image/quickstart 时,应等待后续 Console Trial Kit 切片,不能把本页命令误认为当前公开下载物。
|
||||
D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。
|
||||
|
||||
## 前置条件
|
||||
|
||||
@@ -54,7 +54,7 @@ ssh -L 5701:127.0.0.1:5701 router.example
|
||||
## 使用
|
||||
|
||||
1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。
|
||||
2. 在“任务”中选择 Task,核对 revision/content fence 后才能“运行一次”。
|
||||
2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。
|
||||
3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度。
|
||||
4. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||
5. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
readPrivateLocalCommandFile,
|
||||
} from '@qinglong/local-command-file';
|
||||
import {
|
||||
installLocalOwnerCredentialPresentation,
|
||||
LocalOwnerSecretDeliveryError,
|
||||
openLocalOwnerConsole,
|
||||
type ClaimLocalOwnerFromDeliveriesRequest,
|
||||
@@ -71,6 +72,16 @@ export interface AcknowledgeLocalOwnerDeliveryCommand {
|
||||
};
|
||||
}
|
||||
|
||||
export interface InstallLocalOwnerCredentialPresentationCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.credential-presentation.install-from-delivery';
|
||||
readonly options: OpenLocalOwnerConsoleOptions;
|
||||
readonly request: {
|
||||
readonly credentialMutationId: string;
|
||||
readonly destinationFilePath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssueLocalOwnerCredentialRecoveryCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'owner.credential-recovery.issue';
|
||||
@@ -101,6 +112,7 @@ export type LocalOwnerCommand =
|
||||
| ClaimLocalOwnerCommand
|
||||
| InspectLocalOwnerDeliveryCommand
|
||||
| AcknowledgeLocalOwnerDeliveryCommand
|
||||
| InstallLocalOwnerCredentialPresentationCommand
|
||||
| IssueLocalOwnerCredentialRecoveryCommand
|
||||
| CompleteLocalOwnerCredentialRecoveryCommand;
|
||||
|
||||
@@ -153,6 +165,7 @@ function normalizeCommand(value: unknown): Readonly<LocalOwnerCommand> {
|
||||
'owner.claim.from-deliveries',
|
||||
'owner.delivery.inspect',
|
||||
'owner.delivery.acknowledge',
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
'owner.credential-recovery.issue',
|
||||
'owner.credential-recovery.complete',
|
||||
];
|
||||
@@ -222,6 +235,20 @@ function validateAcknowledgementRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function validatePresentationInstallRequest(
|
||||
value: InstallLocalOwnerCredentialPresentationCommand['request'],
|
||||
): void {
|
||||
if (
|
||||
!exactKeys(value, ['credentialMutationId', 'destinationFilePath']) ||
|
||||
!UUID_V4_PATTERN.test(value.credentialMutationId) ||
|
||||
typeof value.destinationFilePath !== 'string'
|
||||
) {
|
||||
throw new LocalOwnerCliConfigurationError(
|
||||
'credential presentation install request is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
let current = error;
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
@@ -355,6 +382,22 @@ async function execute(
|
||||
ttlMs: acknowledgement.ttlMs,
|
||||
});
|
||||
}
|
||||
case 'owner.credential-presentation.install-from-delivery': {
|
||||
validatePresentationInstallRequest(command.request);
|
||||
const result = installLocalOwnerCredentialPresentation({
|
||||
deploymentRoot: command.options.deploymentRoot,
|
||||
deliveryFilePath: console.credentialDeliveryPath(
|
||||
command.request.credentialMutationId,
|
||||
),
|
||||
destinationFilePath: command.request.destinationFilePath,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
credentialMutationId: result.credentialMutationId,
|
||||
});
|
||||
}
|
||||
case 'owner.credential-recovery.issue': {
|
||||
const result = await console.credentialRecovery.issue(command.request);
|
||||
const delivery = deliveryAfterMutation(result.status, () =>
|
||||
|
||||
@@ -145,6 +145,64 @@ test('completes fresh Owner and credential recovery ceremonies without returning
|
||||
assert.equal(claimed.status, 'inserted');
|
||||
assert.equal(claimed.role, 'owner');
|
||||
assert.equal(JSON.stringify(claimed).includes('secret'), false);
|
||||
const credentialFilePath = path.join(
|
||||
state.options.deploymentRoot,
|
||||
'owner-credential.json',
|
||||
);
|
||||
const installed = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
{ credentialMutationId, destinationFilePath: credentialFilePath },
|
||||
'04b-install-presentation',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(installed, {
|
||||
schemaVersion: 1,
|
||||
operation: 'owner.credential-presentation.install-from-delivery',
|
||||
status: 'installed',
|
||||
credentialMutationId,
|
||||
});
|
||||
const presentation = JSON.parse(fs.readFileSync(credentialFilePath, 'utf8'));
|
||||
assert.equal(
|
||||
presentation.kind,
|
||||
'qinglong3-local-identity-credential-presentation',
|
||||
);
|
||||
assert.match(
|
||||
presentation.token,
|
||||
/^ql3c_own_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/,
|
||||
);
|
||||
assert.equal(fs.statSync(credentialFilePath).mode & 0o777, 0o600);
|
||||
const replayedInstall = await runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
{ credentialMutationId, destinationFilePath: credentialFilePath },
|
||||
'04c-replay-presentation',
|
||||
),
|
||||
);
|
||||
assert.equal(replayedInstall.status, 'existing');
|
||||
assertNoSecretFields(installed);
|
||||
assertNoSecretFields(replayedInstall);
|
||||
const escapedDestination = path.join(
|
||||
os.tmpdir(),
|
||||
`ql3-owner-credential-escape-${process.pid}.json`,
|
||||
);
|
||||
await assert.rejects(
|
||||
runner.run(
|
||||
commandFile(
|
||||
state,
|
||||
'owner.credential-presentation.install-from-delivery',
|
||||
{
|
||||
credentialMutationId,
|
||||
destinationFilePath: escapedDestination,
|
||||
},
|
||||
'04d-reject-escaped-presentation',
|
||||
),
|
||||
),
|
||||
/destinationFilePath must be a descendant of deploymentRoot/,
|
||||
);
|
||||
assert.equal(fs.existsSync(escapedDestination), false);
|
||||
for (const [purpose, mutationId, digest, suffix] of [
|
||||
[
|
||||
'credential-provisioning',
|
||||
|
||||
@@ -46,6 +46,12 @@ export {
|
||||
type LocalOwnerSecretDeliverySummary,
|
||||
type LocalOwnerSecretRecoverySummary,
|
||||
} from '../delivery/secretDelivery';
|
||||
export {
|
||||
LocalOwnerCredentialPresentationInstallError,
|
||||
installLocalOwnerCredentialPresentation,
|
||||
type InstallLocalOwnerCredentialPresentationOptions,
|
||||
type LocalOwnerCredentialPresentationInstallSummary,
|
||||
} from '../delivery/credentialPresentationInstaller';
|
||||
export {
|
||||
LocalOwnerPepperConfigurationError,
|
||||
LocalOwnerPepperConflictError,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { normalizeLocalOwnerBootstrapSecretDeliveryRecord } from './secret-delivery/ceremonyContracts';
|
||||
import { formatApiCredentialToken } from '@qinglong/runtime-core/api-credential-token';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_DELIVERY_BYTES = 4 * 1024;
|
||||
const MAX_PRESENTATION_BYTES = 1024;
|
||||
|
||||
export interface InstallLocalOwnerCredentialPresentationOptions {
|
||||
readonly deploymentRoot: string;
|
||||
readonly deliveryFilePath: string;
|
||||
readonly destinationFilePath: string;
|
||||
}
|
||||
|
||||
export interface LocalOwnerCredentialPresentationInstallSummary {
|
||||
readonly status: 'installed' | 'existing';
|
||||
readonly credentialMutationId: string;
|
||||
}
|
||||
|
||||
export class LocalOwnerCredentialPresentationInstallError extends Error {
|
||||
readonly code = 'LOCAL_OWNER_CREDENTIAL_PRESENTATION_INSTALL_FAILED';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Owner credential presentation install failed: ${message}`);
|
||||
this.name = 'LocalOwnerCredentialPresentationInstallError';
|
||||
}
|
||||
}
|
||||
|
||||
interface PrivateFile {
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly size: bigint;
|
||||
}
|
||||
|
||||
function boundedPath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
`${label} must be a bounded normalized absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'a stable POSIX user is required',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function descendant(root: string, candidate: string, label: string): void {
|
||||
const relative = path.relative(root, candidate);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function privateDirectory(
|
||||
directoryPath: string,
|
||||
ownerUid: number,
|
||||
): PrivateFile {
|
||||
const stat = fs.lstatSync(directoryPath, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== ownerUid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority directories must be private owned real directories',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ device: stat.dev, inode: stat.ino, size: stat.size });
|
||||
}
|
||||
|
||||
function privateFile(
|
||||
filePath: string,
|
||||
ownerUid: number,
|
||||
maximumBytes: number,
|
||||
): PrivateFile {
|
||||
const stat = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== ownerUid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o600 ||
|
||||
stat.size < 1n ||
|
||||
stat.size > BigInt(maximumBytes)
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority files must be bounded private owned regular files',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ device: stat.dev, inode: stat.ino, size: stat.size });
|
||||
}
|
||||
|
||||
function sameDirectory(
|
||||
directoryPath: string,
|
||||
ownerUid: number,
|
||||
expected: PrivateFile,
|
||||
): void {
|
||||
const current = privateDirectory(directoryPath, ownerUid);
|
||||
if (current.device !== expected.device || current.inode !== expected.inode) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'destination directory identity changed during installation',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readPrivateJson(
|
||||
filePath: string,
|
||||
ownerUid: number,
|
||||
maximumBytes: number,
|
||||
): unknown {
|
||||
const expected = privateFile(filePath, ownerUid, maximumBytes);
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== expected.device ||
|
||||
opened.ino !== expected.inode ||
|
||||
opened.size !== expected.size
|
||||
) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority file identity changed while opening',
|
||||
);
|
||||
}
|
||||
material = fs.readFileSync(descriptor);
|
||||
return JSON.parse(material.toString('utf8')) as unknown;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerCredentialPresentationInstallError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'authority file is invalid',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function presentation(value: unknown): Readonly<{
|
||||
schemaVersion: 1;
|
||||
kind: 'qinglong3-local-identity-credential-presentation';
|
||||
token: string;
|
||||
}> {
|
||||
const record = normalizeLocalOwnerBootstrapSecretDeliveryRecord(value);
|
||||
if (record.kind !== 'credential') {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'delivery is not a credential record',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: formatApiCredentialToken(record.credentialId, record.secret),
|
||||
});
|
||||
}
|
||||
|
||||
function existingPresentation(
|
||||
filePath: string,
|
||||
ownerUid: number,
|
||||
expected: ReturnType<typeof presentation>,
|
||||
): boolean {
|
||||
const current = readPrivateJson(filePath, ownerUid, MAX_PRESENTATION_BYTES);
|
||||
return JSON.stringify(current) === JSON.stringify(expected);
|
||||
}
|
||||
|
||||
function syncDirectory(directoryPath: string): void {
|
||||
const descriptor = fs.openSync(directoryPath, 'r');
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function installLocalOwnerCredentialPresentation(
|
||||
options: InstallLocalOwnerCredentialPresentationOptions,
|
||||
): Readonly<LocalOwnerCredentialPresentationInstallSummary> {
|
||||
try {
|
||||
const deploymentRoot = boundedPath(
|
||||
options?.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const deliveryFilePath = boundedPath(
|
||||
options?.deliveryFilePath,
|
||||
'deliveryFilePath',
|
||||
);
|
||||
const destinationFilePath = boundedPath(
|
||||
options?.destinationFilePath,
|
||||
'destinationFilePath',
|
||||
);
|
||||
descendant(deploymentRoot, deliveryFilePath, 'deliveryFilePath');
|
||||
descendant(deploymentRoot, destinationFilePath, 'destinationFilePath');
|
||||
if (deliveryFilePath === destinationFilePath) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'delivery and destination files must be distinct',
|
||||
);
|
||||
}
|
||||
const ownerUid = uid();
|
||||
const rootIdentity = privateDirectory(deploymentRoot, ownerUid);
|
||||
const destinationDirectory = path.dirname(destinationFilePath);
|
||||
const destinationDirectoryIdentity = privateDirectory(
|
||||
destinationDirectory,
|
||||
ownerUid,
|
||||
);
|
||||
const canonicalRoot = fs.realpathSync(deploymentRoot);
|
||||
const canonicalDelivery = fs.realpathSync(deliveryFilePath);
|
||||
const canonicalDestination = path.join(
|
||||
fs.realpathSync(destinationDirectory),
|
||||
path.basename(destinationFilePath),
|
||||
);
|
||||
descendant(canonicalRoot, canonicalDelivery, 'deliveryFilePath');
|
||||
descendant(canonicalRoot, canonicalDestination, 'destinationFilePath');
|
||||
const delivery = normalizeLocalOwnerBootstrapSecretDeliveryRecord(
|
||||
readPrivateJson(deliveryFilePath, ownerUid, MAX_DELIVERY_BYTES),
|
||||
);
|
||||
if (delivery.kind !== 'credential') {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'delivery is not a credential record',
|
||||
);
|
||||
}
|
||||
const expected = presentation(delivery);
|
||||
if (fs.existsSync(destinationFilePath)) {
|
||||
if (!existingPresentation(destinationFilePath, ownerUid, expected)) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'destination conflicts with the delivered credential',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credentialMutationId: delivery.mutationId,
|
||||
});
|
||||
}
|
||||
const temporaryPath = path.join(
|
||||
destinationDirectory,
|
||||
`.owner-credential-presentation-${randomUUID()}.tmp`,
|
||||
);
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
const serialized = `${JSON.stringify(expected)}\n`;
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_PRESENTATION_BYTES) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'credential presentation exceeds its byte budget',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(descriptor, serialized, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
sameDirectory(
|
||||
destinationDirectory,
|
||||
ownerUid,
|
||||
destinationDirectoryIdentity,
|
||||
);
|
||||
fs.linkSync(temporaryPath, destinationFilePath);
|
||||
syncDirectory(destinationDirectory);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'EEXIST' &&
|
||||
existingPresentation(destinationFilePath, ownerUid, expected)
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
credentialMutationId: delivery.mutationId,
|
||||
});
|
||||
}
|
||||
if (error instanceof LocalOwnerCredentialPresentationInstallError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'credential presentation could not be published',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
syncDirectory(destinationDirectory);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'ENOENT'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
sameDirectory(deploymentRoot, ownerUid, rootIdentity);
|
||||
sameDirectory(destinationDirectory, ownerUid, destinationDirectoryIdentity);
|
||||
if (!existingPresentation(destinationFilePath, ownerUid, expected)) {
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'published credential presentation is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'installed',
|
||||
credentialMutationId: delivery.mutationId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalOwnerCredentialPresentationInstallError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalOwnerCredentialPresentationInstallError(
|
||||
'credential presentation installation failed closed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const SCHEMA = 'qinglong/alpha-local-trial-kit@v4';
|
||||
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v2';
|
||||
const SCHEMA = 'qinglong/alpha-local-trial-kit@v5';
|
||||
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v3';
|
||||
const QUICKSTART_TEMPLATE = path.join(
|
||||
DEFAULT_ROOT,
|
||||
'scripts/templates/ql3-local-alpha-quickstart.sh',
|
||||
@@ -40,6 +40,7 @@ const VERIFICATION = Object.freeze({
|
||||
operator128MiBEntrypoint: 'passed',
|
||||
operatorPackageInventory: 'passed',
|
||||
freshOwnerJourney: 'passed',
|
||||
ownerCredentialPresentation: 'passed',
|
||||
edgeFreshLifecycle: 'passed',
|
||||
standaloneFreshLifecycle: 'passed',
|
||||
localApiCancellation: 'passed',
|
||||
@@ -49,6 +50,7 @@ function verificationGates(variant) {
|
||||
return Object.freeze({
|
||||
...VERIFICATION,
|
||||
consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable',
|
||||
firstAutomationJourney: variant === 'console' ? 'passed' : 'not_applicable',
|
||||
});
|
||||
}
|
||||
const WORKFLOW_IDENTITY = Object.freeze({
|
||||
@@ -302,7 +304,10 @@ function validateVerificationEvidence(document, expected) {
|
||||
document.workflow.job !== WORKFLOW_IDENTITY.job ||
|
||||
!DECIMAL_ID_PATTERN.test(document.workflow.runId || '') ||
|
||||
!ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') ||
|
||||
!exactKeys(document.gates, Object.keys(verificationGates(expected.variant))) ||
|
||||
!exactKeys(
|
||||
document.gates,
|
||||
Object.keys(verificationGates(expected.variant)),
|
||||
) ||
|
||||
JSON.stringify(document.gates) !==
|
||||
JSON.stringify(verificationGates(expected.variant))
|
||||
) {
|
||||
@@ -618,7 +623,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
|
||||
0o700,
|
||||
);
|
||||
const manifest = {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
schema: SCHEMA,
|
||||
maturity: 'alpha_candidate_not_public_release',
|
||||
product: 'local',
|
||||
@@ -721,7 +726,7 @@ function auditLocalAlphaTrialKit(options) {
|
||||
'readme',
|
||||
'verification',
|
||||
]) ||
|
||||
manifest.schemaVersion !== 5 ||
|
||||
manifest.schemaVersion !== 6 ||
|
||||
manifest.schema !== SCHEMA ||
|
||||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
|
||||
manifest.product !== 'local' ||
|
||||
|
||||
@@ -90,8 +90,7 @@ function inspectImages(applicationImage, operatorImage, variant) {
|
||||
applicationLabels?.['io.qinglong.profile'] !== expectedProfile ||
|
||||
applicationLabels?.['io.qinglong.ai'] !== 'excluded' ||
|
||||
(variant === 'console'
|
||||
? applicationLabels?.['io.qinglong.local.console'] !==
|
||||
'offline-loopback'
|
||||
? applicationLabels?.['io.qinglong.local.console'] !== 'offline-loopback'
|
||||
: applicationLabels?.['io.qinglong.local.console'] !== undefined) ||
|
||||
operatorLabels?.['io.qinglong.lifecycle'] !== 'short-lived' ||
|
||||
operatorLabels?.['io.qinglong.authority'] !== 'local-owner-management' ||
|
||||
@@ -285,6 +284,34 @@ function establishFirstOwner(state) {
|
||||
) {
|
||||
fail('first Owner ceremony did not converge');
|
||||
}
|
||||
writePrivateJson(path.join(state.root, 'owner-credential-install.json'), {
|
||||
schemaVersion: 1,
|
||||
operation: 'owner.credential-presentation.install-from-delivery',
|
||||
options: {
|
||||
deploymentRoot: '/var/lib/qinglong3',
|
||||
databasePath: '/var/lib/qinglong3/qinglong3.sqlite',
|
||||
pepperPath: '/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper',
|
||||
pepperKeyId: 'owner-v1',
|
||||
secretDeliveryDirectory: '/var/lib/qinglong3/owner-delivery',
|
||||
profile: state.profile,
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
request: {
|
||||
credentialMutationId,
|
||||
destinationFilePath: '/var/lib/qinglong3/owner-credential.json',
|
||||
},
|
||||
});
|
||||
const presentation = runOperator(
|
||||
state,
|
||||
'owner',
|
||||
'owner-credential-install.json',
|
||||
);
|
||||
if (
|
||||
presentation.status !== 'installed' ||
|
||||
presentation.credentialMutationId !== credentialMutationId
|
||||
) {
|
||||
fail('Owner credential presentation did not install');
|
||||
}
|
||||
for (const acknowledgement of [
|
||||
{
|
||||
file: 'owner-credential-ack.json',
|
||||
@@ -312,10 +339,68 @@ function establishFirstOwner(state) {
|
||||
provisioned: true,
|
||||
challenged: true,
|
||||
claimed: true,
|
||||
credentialPresentationInstalled: true,
|
||||
acknowledged: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createFirstAutomationTask(state) {
|
||||
if (state.variant !== 'console') {
|
||||
return Object.freeze({ status: 'not_applicable' });
|
||||
}
|
||||
writePrivateJson(path.join(state.root, 'alpha-first-task.json'), {
|
||||
schemaVersion: 1,
|
||||
operation: 'task.put',
|
||||
options: {
|
||||
deploymentRoot: '/var/lib/qinglong3',
|
||||
databasePath: '/var/lib/qinglong3/qinglong3.sqlite',
|
||||
profile: state.profile,
|
||||
ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers',
|
||||
credentialFilePath: '/var/lib/qinglong3/owner-credential.json',
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
request: {
|
||||
projectId: 'default',
|
||||
taskId: 'alpha-first-automation',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f8680-143d-4000-8000-000000000031',
|
||||
requestId: 'alpha-trial-first-task',
|
||||
failureAuditEventId: '019f8680-143d-4000-8000-000000000032',
|
||||
name: 'QingLong 3.0 first automation',
|
||||
description:
|
||||
'A bounded offline task proving the fresh Alpha installation can execute work',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['qinglong3-alpha-first-automation'],
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: { 'qinglong.alpha.example': 'true' },
|
||||
enabled: true,
|
||||
occurredAtMs: 1_785_254_400_031,
|
||||
},
|
||||
});
|
||||
const result = runOperator(state, 'task', 'alpha-first-task.json');
|
||||
if (
|
||||
result.status !== 'created' ||
|
||||
result.task?.taskId !== 'alpha-first-automation' ||
|
||||
result.task?.revision !== 1 ||
|
||||
result.task?.enabled !== true
|
||||
) {
|
||||
fail('first automation Task did not converge');
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created',
|
||||
taskId: result.task.taskId,
|
||||
revision: result.task.revision,
|
||||
});
|
||||
}
|
||||
|
||||
function writeApplicationConfig(state) {
|
||||
writePrivateJson(path.join(state.root, 'local-application.json'), {
|
||||
schema: 'qinglong/local-application-process@v2',
|
||||
@@ -346,8 +431,7 @@ function writeApplicationConfig(state) {
|
||||
writePrivateJson(path.join(state.root, 'local-api.json'), {
|
||||
schema: 'qinglong/local-api-process@v1',
|
||||
deploymentRoot: '/var/lib/qinglong3',
|
||||
applicationConfigFilePath:
|
||||
'/var/lib/qinglong3/local-application.json',
|
||||
applicationConfigFilePath: '/var/lib/qinglong3/local-application.json',
|
||||
ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers',
|
||||
listener: { host: '127.0.0.1', port: 5700 },
|
||||
});
|
||||
@@ -358,7 +442,39 @@ function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function consoleSurfaceContract() {
|
||||
function credentialToken(state) {
|
||||
const value = JSON.parse(
|
||||
fs.readFileSync(path.join(state.root, 'owner-credential.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
value?.schemaVersion !== 1 ||
|
||||
value?.kind !== 'qinglong3-local-identity-credential-presentation' ||
|
||||
typeof value?.token !== 'string' ||
|
||||
!/^ql3c_own_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/u.test(value.token)
|
||||
) {
|
||||
fail('installed Owner credential presentation is invalid');
|
||||
}
|
||||
return value.token;
|
||||
}
|
||||
|
||||
async function apiRequest(pathname, token, options = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:5700${pathname}`, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
...options,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(options.body === undefined
|
||||
? {}
|
||||
: { 'content-type': 'application/json' }),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const body = await response.json();
|
||||
return Object.freeze({ status: response.status, body });
|
||||
}
|
||||
|
||||
async function consoleSurfaceContract(state) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
@@ -380,10 +496,81 @@ async function consoleSurfaceContract() {
|
||||
`Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`,
|
||||
);
|
||||
}
|
||||
const token = credentialToken(state);
|
||||
const task = await apiRequest(
|
||||
'/api/v3/projects/default/tasks/alpha-first-automation',
|
||||
token,
|
||||
);
|
||||
if (
|
||||
task.status !== 200 ||
|
||||
task.body?.task?.revision !== 1 ||
|
||||
!/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '')
|
||||
) {
|
||||
fail(
|
||||
'starter Task is not visible through the authenticated Console API',
|
||||
);
|
||||
}
|
||||
const started = await apiRequest(
|
||||
'/api/v3/projects/default/tasks/alpha-first-automation/runs',
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: '019f8680-143d-7000-8000-000000000041',
|
||||
expectedRevision: task.body.task.revision,
|
||||
expectedContentDigest: task.body.task.contentDigest,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (
|
||||
started.status !== 202 ||
|
||||
started.body?.status !== 'accepted' ||
|
||||
typeof started.body?.runId !== 'string' ||
|
||||
typeof started.body?.attemptId !== 'string'
|
||||
) {
|
||||
fail('starter Task did not accept one fenced Run');
|
||||
}
|
||||
let terminal;
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const current = await apiRequest(
|
||||
`/api/v3/projects/default/runs/${started.body.runId}`,
|
||||
token,
|
||||
);
|
||||
if (current.status !== 200) fail('starter Run became unreadable');
|
||||
if (
|
||||
['succeeded', 'failed', 'cancelled', 'timed_out'].includes(
|
||||
current.body?.run?.status,
|
||||
)
|
||||
) {
|
||||
terminal = current.body.run.status;
|
||||
break;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
if (terminal !== 'succeeded') {
|
||||
fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`);
|
||||
}
|
||||
const log = await apiRequest(
|
||||
`/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`,
|
||||
token,
|
||||
);
|
||||
const logText =
|
||||
log.status === 200 && log.body?.status === 'available'
|
||||
? Buffer.from(log.body.content, 'base64').toString('utf8')
|
||||
: '';
|
||||
if (!logText.includes('qinglong3-alpha-first-automation')) {
|
||||
fail('starter Run log does not contain the bounded work marker');
|
||||
}
|
||||
return Object.freeze({
|
||||
listener: '127.0.0.1:5700',
|
||||
rootStatus: 200,
|
||||
unauthenticatedApiStatus: 401,
|
||||
firstAutomation: Object.freeze({
|
||||
taskId: 'alpha-first-automation',
|
||||
runStatus: terminal,
|
||||
logMarkerObserved: true,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -458,7 +645,7 @@ async function runApplication(state) {
|
||||
surfacePromise = (async () => {
|
||||
try {
|
||||
if (state.variant === 'console') {
|
||||
surface = await consoleSurfaceContract();
|
||||
surface = await consoleSurfaceContract(state);
|
||||
}
|
||||
} catch (error) {
|
||||
surfaceError = error;
|
||||
@@ -542,6 +729,7 @@ async function main() {
|
||||
try {
|
||||
const setup = prepareFreshAuthority(state);
|
||||
const owner = establishFirstOwner(state);
|
||||
const firstAutomation = createFirstAutomationTask(state);
|
||||
writeApplicationConfig(state);
|
||||
const lifecycle = await runApplication(state);
|
||||
const database = new DatabaseSync(path.join(root, 'qinglong3.sqlite'), {
|
||||
@@ -565,8 +753,8 @@ async function main() {
|
||||
fail('durable SQLite result is invalid');
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
schema: 'qinglong/local-alpha-trial-kit-live@v2',
|
||||
schemaVersion: 3,
|
||||
schema: 'qinglong/local-alpha-trial-kit-live@v3',
|
||||
variant: options.variant,
|
||||
profile: options.profile,
|
||||
architecture: images.architecture,
|
||||
@@ -576,6 +764,7 @@ async function main() {
|
||||
},
|
||||
setup,
|
||||
owner,
|
||||
firstAutomation,
|
||||
lifecycle,
|
||||
sqliteIntegrity: integrity,
|
||||
activeOwnerBindings: ownerCount,
|
||||
|
||||
@@ -102,10 +102,16 @@ EOF
|
||||
cat >"$data_root/owner-claim.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"owner.claim.from-deliveries","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","pepperPath":"/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper","pepperKeyId":"owner-v1","secretDeliveryDirectory":"/var/lib/qinglong3/owner-delivery","profile":"$profile","busyTimeoutMs":100},"request":{"projectId":"default","mutationId":"019f8680-143d-4000-8000-000000000023","requestId":"alpha-trial-owner-claim","credentialMutationId":"019f8680-143d-4000-8000-000000000021","challengeMutationId":"019f8680-143d-4000-8000-000000000022"}}
|
||||
EOF
|
||||
cat >"$data_root/owner-credential-install.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"owner.credential-presentation.install-from-delivery","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","pepperPath":"/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper","pepperKeyId":"owner-v1","secretDeliveryDirectory":"/var/lib/qinglong3/owner-delivery","profile":"$profile","busyTimeoutMs":100},"request":{"credentialMutationId":"019f8680-143d-4000-8000-000000000021","destinationFilePath":"/var/lib/qinglong3/owner-credential.json"}}
|
||||
EOF
|
||||
cat >"$data_root/local-application.json" <<EOF
|
||||
{"schema":"qinglong/local-application-process@v2","instanceId":"alpha-trial-local","profile":"$profile","storage":{"mode":"fresh","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","busyTimeoutMs":100},"runtime":{"receiptRoot":"/var/lib/qinglong3/receipts","artifactRoot":"/var/lib/qinglong3/artifacts","secretKeyringPath":"/var/lib/qinglong3/local-secret-keyring.json"},"pluginPackages":{"stagingRoot":"/var/lib/qinglong3/plugin-staging","activationRoot":"/var/lib/qinglong3/plugin-activation","recoverySource":{"mode":"disabled"},"pageSize":4,"maxPages":4,"taskPublicationPageSize":4,"taskPublicationMaxPages":4},"ai":{"deployment":"excluded"}}
|
||||
EOF
|
||||
if [ "$VARIANT" = console ]; then
|
||||
cat >"$data_root/alpha-first-task.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"task.put","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","profile":"$profile","ownerPepperKeyringDirectory":"/var/lib/qinglong3/owner-peppers","credentialFilePath":"/var/lib/qinglong3/owner-credential.json","busyTimeoutMs":100},"request":{"projectId":"default","taskId":"alpha-first-automation","expectedRevision":null,"mutationId":"019f8680-143d-4000-8000-000000000031","requestId":"alpha-trial-first-task","failureAuditEventId":"019f8680-143d-4000-8000-000000000032","name":"QingLong 3.0 first automation","description":"A bounded offline task proving the fresh Alpha installation can execute work","kind":"command","spec":{"schema":"qinglong/command@v1","config":{"command":{"kind":"argv","file":"/bin/echo","args":["qinglong3-alpha-first-automation"]}}},"labels":{"qinglong.alpha.example":"true"},"enabled":true,"occurredAtMs":1785254400031}}
|
||||
EOF
|
||||
cat >"$data_root/local-api.json" <<EOF
|
||||
{"schema":"qinglong/local-api-process@v1","deploymentRoot":"/var/lib/qinglong3","applicationConfigFilePath":"/var/lib/qinglong3/local-application.json","ownerPepperKeyringDirectory":"/var/lib/qinglong3/owner-peppers","listener":{"host":"127.0.0.1","port":5700}}
|
||||
EOF
|
||||
@@ -136,6 +142,12 @@ grep -q '"status":"inserted"' "$data_root/results/owner-challenge.json.result.js
|
||||
run_operator owner owner-claim.json
|
||||
grep -q '"status":"inserted"' "$data_root/results/owner-claim.json.result.json" || fail 'Owner claim did not report inserted'
|
||||
grep -q '"role":"owner"' "$data_root/results/owner-claim.json.result.json" || fail 'Owner claim did not establish the owner role'
|
||||
run_operator owner owner-credential-install.json
|
||||
grep -q '"status":"installed"' "$data_root/results/owner-credential-install.json.result.json" || fail 'Owner credential presentation was not installed'
|
||||
if [ "$VARIANT" = console ]; then
|
||||
run_operator task alpha-first-task.json
|
||||
grep -q '"status":"created"' "$data_root/results/alpha-first-task.json.result.json" || fail 'first automation Task was not created'
|
||||
fi
|
||||
|
||||
ready=0
|
||||
cleanup() {
|
||||
@@ -174,6 +186,7 @@ printf '%s\n' \
|
||||
"QingLong 3.0 Local Alpha is active ($VARIANT, $profile, $ARCHITECTURE)." \
|
||||
"Data root: $data_root" \
|
||||
"Owner deliveries: $data_root/owner-delivery" \
|
||||
"Owner credential presentation: $data_root/owner-credential.json" \
|
||||
"Logs: docker logs $container_name" \
|
||||
"Stop: docker stop --time 30 $container_name" \
|
||||
"Remove container: docker rm $container_name" \
|
||||
@@ -181,5 +194,6 @@ printf '%s\n' \
|
||||
if [ "$VARIANT" = console ]; then
|
||||
printf '%s\n' \
|
||||
'Console: http://127.0.0.1:5700/' \
|
||||
'Starter Task: alpha-first-automation (review its revision/content fence in Console before running it).' \
|
||||
'Remote access: create an SSH tunnel to 127.0.0.1:5700; do not expose the port on LAN or the public Internet.'
|
||||
fi
|
||||
|
||||
@@ -166,7 +166,7 @@ function adapters(overrides = {}, variant = 'headless') {
|
||||
test('materializes and offline-audits one closed two-image trial kit', (t) => {
|
||||
const paths = fixture(t);
|
||||
const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters());
|
||||
assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v4');
|
||||
assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v5');
|
||||
assert.equal(manifest.variant, 'headless');
|
||||
assert.equal(manifest.sourceRevision, revision);
|
||||
assert.equal(manifest.architecture, 'arm64');
|
||||
@@ -227,6 +227,8 @@ test('materializes a distinct loopback Console trial kit without widening the he
|
||||
),
|
||||
);
|
||||
assert.equal(verification.gates.consoleLiveJourney, 'passed');
|
||||
assert.equal(verification.gates.firstAutomationJourney, 'passed');
|
||||
assert.equal(verification.gates.ownerCredentialPresentation, 'passed');
|
||||
const quickstartContents = fs.readFileSync(
|
||||
path.join(paths.outputRoot, 'quickstart.sh'),
|
||||
'utf8',
|
||||
@@ -236,6 +238,7 @@ test('materializes a distinct loopback Console trial kit without widening the he
|
||||
assert.match(quickstartContents, /application_config=local-api\.json/);
|
||||
assert.match(quickstartContents, /"host":"127\.0\.0\.1","port":5700/);
|
||||
assert.match(quickstartContents, /Console: http:\/\/127\.0\.0\.1:5700\//);
|
||||
assert.match(quickstartContents, /alpha-first-automation/);
|
||||
assert.match(quickstartContents, /do not expose the port on LAN/);
|
||||
const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot });
|
||||
assert.equal(report.compatible, true);
|
||||
@@ -391,6 +394,7 @@ case " $* " in
|
||||
*'/owner-provision.json'*) printf '%s\\n' '{"status":"inserted"}'; exit 0 ;;
|
||||
*'/owner-challenge.json'*) printf '%s\\n' '{"status":"inserted"}'; exit 0 ;;
|
||||
*'/owner-claim.json'*) printf '%s\\n' '{"status":"inserted","role":"owner"}'; exit 0 ;;
|
||||
*'/owner-credential-install.json'*) printf '%s\\n' '{"status":"installed"}'; exit 0 ;;
|
||||
esac
|
||||
exit 1
|
||||
`,
|
||||
|
||||
@@ -172,10 +172,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localOwnerConsole.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 19,
|
||||
sourceFiles: 20,
|
||||
rootSourceFiles: 0,
|
||||
rootSourceLines: 0,
|
||||
nestedSourceFiles: 19,
|
||||
nestedSourceFiles: 20,
|
||||
rootSourceFileRoles: {},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user