From b7a8104d9fdb262bf635295336a51d8df984d3b8 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 28 Aug 2026 03:56:54 +0800 Subject: [PATCH] feat(ql3): ship runnable local alpha quickstart --- .github/workflows/ql3-ci.yml | 10 ++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...DR-0511-runnable-local-alpha-quickstart.md | 78 +++++++++ docs/adr/README.md | 1 + docs/operations/ql3-alpha-candidate.md | 23 ++- docs/operations/ql3-local-alpha-trial-kit.md | 36 +++- scripts/ql3-local-alpha-trial-kit-bundle.cjs | 74 +++++++- scripts/ql3-local-operator-image-audit.cjs | 4 + .../templates/ql3-local-alpha-quickstart.sh | 162 +++++++++++++++++ .../back/ql3LocalAlphaTrialKitBundle.test.cjs | 165 +++++++++++++++++- 10 files changed, 540 insertions(+), 15 deletions(-) create mode 100644 docs/adr/ADR-0511-runnable-local-alpha-quickstart.md create mode 100644 scripts/templates/ql3-local-alpha-quickstart.sh diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index efc9786a..e82a386a 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -631,6 +631,16 @@ jobs: --output="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ --mode=audit --bundle="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" + QUICKSTART_CONTAINER="ql3-alpha-quickstart-${{ matrix.image_arch }}" + QUICKSTART_ROOT="${RUNNER_TEMP}/ql3-alpha-quickstart-${{ matrix.image_arch }}" + trap 'docker rm --force "${QUICKSTART_CONTAINER}" >/dev/null 2>&1 || true' EXIT + sh "${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}/quickstart.sh" \ + edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}" + docker stop --time 30 "${QUICKSTART_CONTAINER}" + docker rm "${QUICKSTART_CONTAINER}" + test -s "${QUICKSTART_ROOT}/qinglong3.sqlite" + test -s "${QUICKSTART_ROOT}/owner-delivery/credential-019f8680-143d-4000-8000-000000000021.ready.json" + trap - EXIT - name: Upload the tested native Local Alpha trial kit if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 7ae03ebc..81cd74a8 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-28): +- D-416/ADR-0511(已实现,首份真实 v3 Trial Kit 待授权):阶段产物的判定从“可下载、可验真”推进到“部署者可直接试运行”。Local Trial Kit schema 升为 `qinglong/alpha-local-trial-kit@v3`,每个 amd64/arm64 bundle 新增由唯一模板生成并绑定 archive、两个 exact image ID、source 和 architecture 的 `quickstart.sh`;离线 auditor 会重新渲染 canonical journey,拒绝即使重算 manifest/checksum 的本地脚本改写。目标路由/NAS 或单节点只需 POSIX shell、`sha256sum` 和 Docker,即可在一个尚不存在的隔离目录完成全包 checksum、镜像 load/identity 复核、128 MiB 短生命周期 operator fresh setup、首 Owner 建立,并按 Edge `128 MiB/64 PID` 或 Standalone `256 MiB/256 PID` 启动 Application、等待 `active` 后输出 logs/stop/remove 指令。显式 artifact job 在 upload 前必须执行将要上传的 exact quickstart、检查 SQLite/Owner delivery 并 graceful stop;普通 push 不生成大 archive。聚焦 materializer/milestone/stage/workflow 回归为 `26/26`,完整 backend 为 `1636 total / 1634 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0;package boundary 保持 `18 packages / singleSourcePackages=[] / shallowSourcePackages=[]`,Cluster dependency、122-module Edge import、镜像/版本与 14 档 Local artifact 均 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。历史同源 arm64 镜像对已本地物化为 v3 八文件开发 bundle,184,648,192-byte archive 完成 checksum、canonical audit、load 与 exact identity;Docker Desktop 在 Owner directory ownership 处按既有 POSIX 不等价失败关闭,未冒充原生 Linux 成功,正式 native exact-script 证据由手动 artifact job 产生。该切片不安装宿主 Node.js,不把 Operator 变成 daemon,不增加镜像 layer、workspace package、端口或稳态 RSS,也不触碰 2.x/migration。当前产物是无外部 listener、AI-excluded 的 headless Alpha runtime,不冒充 2.x Web UI、公开 release、生产 HA 或 LTS;首份真实双架构 v3 下载物仍需维护者授权 `produce_alpha_artifacts=true`。 + - D-415/ADR-0510(已实现,首份真实 stage index 待授权):二十天开发形成的 Local 与 Cluster 候选现在有统一、面向部署者的最外层阶段交付入口。新增 `qinglong/alpha-stage-index@v1` finalizer/auditor,仅在显式 `produce_alpha_artifacts=true + alpha_artifact_scope=all` 且 Local/Cluster milestone 都成功后运行;它重新离线审计两个索引,要求 version/source/workflow SHA/ref/run/attempt 完全一致,记录两个 milestone manifest digest,并为 amd64/arm64 写出机器可读选择:低配 Edge/Standalone 只下载一个 Local Trial Kit、稳态只运行 Application 且 Operator 短时启动;Cluster 下载 control/admin/worker,按需增加 control-ai。跨索引审计必须同时持有 stage 与两个 milestone,拒绝跨运行混用、内容/选择漂移和额外文件。该索引不重复十个大 archive,不改变 Local-only/Cluster-only 授权路径,也不冒充受保护 tag、GHCR immutable digest、签名、正式 OCI catalog、生产 deployment lock 或 HA;普通 push/PR 不生成实际阶段 artifact。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS。 - D-414/ADR-0509(已实现,首份真实 Cluster milestone artifact 待授权):开发约二十天后,Cluster 阶段产物不再是八个由 workflow 内联 shell 自报 `passed` 的松散 archive。新增统一 `qinglong/alpha-cluster-image@v1` materializer/auditor,固定 control/control-ai/admin/worker 的 repository、OCI title、non-root user 和 amd64/arm64 identity;每个六文件 bundle 含单镜像 Docker archive、精确 SBOM、绑定 source/workflow SHA/ref/run/attempt/role/architecture/image ID 的 verification evidence、README、manifest 与 `SHA256SUMS`,下载后可无 Docker/网络离线复核闭合文件集和内容 digest。新增完整 CI 后置 `cluster-alpha-milestone` finalizer,重新下载八个 artifact,要求同一 version/source/run/attempt 且八个 image ID/archive digest/verification digest 互异,再上传 `qinglong/alpha-cluster-milestone@v1` 三文件索引;没有该索引的部分 archive 只是失败运行中间件。maturity 明确为 `cluster_integration_candidate_not_public_release`,适用于隔离 registry/K3s/Kubernetes 集成,不冒充受保护 tag、GHCR digest、签名、catalog 或生产 HA。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS;首份实际产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=cluster`。 diff --git a/docs/adr/ADR-0511-runnable-local-alpha-quickstart.md b/docs/adr/ADR-0511-runnable-local-alpha-quickstart.md new file mode 100644 index 00000000..2deffe1c --- /dev/null +++ b/docs/adr/ADR-0511-runnable-local-alpha-quickstart.md @@ -0,0 +1,78 @@ +# ADR-0511:可直接试运行的 Local Alpha Quickstart + +- 状态:Accepted(首份实际 v3 Trial Kit 待维护者授权) +- 日期:2026-08-28 +- 决策:D-416 +- 关联:ADR-0193、ADR-0503、ADR-0504、ADR-0506、ADR-0508、ADR-0510 + +## 背景 + +开发约二十天后,Local Alpha Trial Kit 已经能够闭合 Application/operator 镜像、SBOM、workflow evidence、manifest 和 checksum,并由双架构 milestone 与跨 Profile stage index 导航。但下载者仍需从长篇运维文档手工拼装 setup、首 Owner ceremony、Application config 和受限 `docker run`。现有 artifact 证明“镜像可验证”,还没有把已在 CI 通过的 fresh 用户旅程变成部署者拿到即可执行的阶段产物。 + +这对两类部署者都不合理:低配路由/NAS 不应为了试运行先安装 Node.js 24 或理解完整生产 deployment ceremony;普通单节点用户也不应复制多份含 mutation、UID/GID、resource limit 和 authority path 的 JSON。继续增加索引或证据而不关闭最后一公里,会把工程完整性误报为产品可用性。 + +## 决策 + +### 1. Local Trial Kit v3 必须携带 canonical quickstart + +bundle schema 升为 `qinglong/alpha-local-trial-kit@v3`,闭合目录新增 `quickstart.sh`。materializer 从仓库内唯一模板写入当前 archive 名、Application/operator reference、exact image ID、source revision 和 architecture;manifest 增加 `quickstart` 文件记录,脚本同时进入 `SHA256SUMS`。 + +离线 auditor 不只检查脚本 byte/SHA-256,还根据 manifest 重新渲染 canonical journey 并逐字比较。因此本地重写脚本后即使同时重算 manifest 和 checksum,也不能通过源代码对应版本的 auditor。 + +### 2. 目标机只依赖 POSIX shell、sha256sum 和 Docker + +quickstart 接受 `edge|standalone`、一个尚不存在的 canonical absolute data root,以及可选容器名。它按固定顺序执行: + +1. 校验整个 bundle 的 `SHA256SUMS`,加载单一双镜像 archive; +2. 重新核对两个镜像的 ID、architecture、numeric user、source revision 和 operator lifecycle/network label; +3. 以当前宿主 UID:GID、无网络、只读 rootfs、drop-all capability、no-new-privileges 和 128 MiB operator 上限完成 fresh setup; +4. 通过短生命周期 operator 建立首个 Owner,敏感 delivery 只保存在新建 `0700` data root; +5. 以 Edge `128 MiB/64 PID` 或 Standalone `256 MiB/256 PID` 上限启动 Application,等待结构化 `active` 事件后才报告成功; +6. 输出 logs、graceful stop、container removal 和保留 data root 的明确命令。 + +脚本不需要宿主 Node.js、jq、Compose、网络访问或 root。operator 仍然每次只处理一个 command file 后退出,不成为 daemon 或 sidecar。 + +### 3. quickstart 本身必须经过 exact native artifact 门 + +显式 `workflow_dispatch + produce_alpha_artifacts=true` 的每个 Local architecture job 在 `create → audit` 后,必须从将要上传的目录执行生成出的 `quickstart.sh`,确认 SQLite、Owner delivery、Application active 与 graceful stop,再允许 upload。原有 Edge/Standalone live journey 继续保留;新增门证明的是“下载目录里的 exact 脚本可以驱动 exact 镜像”,不是另一个源码等价测试。 + +### 4. 阶段可用边界保持诚实 + +该 quickstart 只允许 fresh、隔离的新目录,不接受既有目录,不触碰 2.x 数据,不执行 migration/cutover,也不宣称生产升级、HA、公开签名或 LTS。当前 Application 是无外部 listener 的 headless runtime,能验证 3.0 SQLite、Owner authority、调度/插件运行基础和生命周期;它不是 2.x Web UI 的替代品,AI deployment 仍明确 excluded。 + +Cluster 节点继续使用 Cluster Integration Candidate 和 Kubernetes/CloudNativePG 路径,不能复用 Local quickstart。 + +## 被拒绝的替代方案 + +### 只在 README 增加更多手工命令 + +拒绝。文档无法保证 image identity、资源限制、authority path 和初始化顺序不漂移,也不能由 CI 执行下载者实际拿到的 journey。 + +### 只提供 Compose 文件 + +拒绝。Compose 可以描述常驻 Application,但不能安全替代短生命周期 setup 与首 Owner ceremony;把 operator 设为常驻 sidecar会扩大管理 authority。 + +### 提供 Node.js quickstart + +拒绝。它会要求低配目标机额外安装与维护 Node.js,而镜像交付本来只要求容器运行时。 + +### 自动迁移现有 2.x 目录 + +拒绝。Alpha quickstart 没有 reconciliation、review、cutover 和 rollback authority,自动接管既有目录会越过已经冻结的迁移边界。 + +## 影响 + +- Local Alpha 从“可下载、可验真”前进到“可在 fresh 设备目录一条命令完成初始化并启动”; +- bundle 从七个文件增加为八个小文件,不新增镜像 layer、workspace package、runtime dependency、端口、后台 timer 或稳态 RSS; +- Edge/Standalone 继续共用同一镜像,差异只体现在 quickstart 的 memory/PID limit; +- `@v2` 仍是历史 verification-evidence 阶段格式,首份面向部署者的实际下载物必须使用 `@v3`; +- 首份真实 amd64/arm64 v3 Trial Kit、Local milestone 与 stage index 仍需维护者显式授权 artifact workflow,普通 push 不生成大归档。 + +## 验证 + +- 单元测试覆盖 v3 八文件物化、POSIX shell 语法、exact image reference 注入、脚本/manifest/checksum 篡改,以及重算全部摘要后的非 canonical 脚本拒绝; +- Local milestone 与 Alpha stage index 回归证明 schema 升级没有放宽跨架构、跨 run/attempt 和跨 Profile 闭合; +- Local operator workflow 静态审计要求 `create → audit → quickstart → stop → upload` 顺序; +- 聚焦门为 `26/26`,完整 backend 为 `1636 total / 1634 pass / 2 conditional skip / 0 fail`,18-package clean build/test、package/dependency/Edge import、镜像/版本和 14 档 Local artifact audit 全部通过; +- 历史同源 arm64 Application/operator 已物化为 184,648,192-byte v3 开发 bundle并通过 checksum、canonical audit、load 与 exact identity;Docker Desktop 在 Owner POSIX directory ownership 处按已知不等价失败关闭,未被记录为 native quickstart 成功; +- 手动 artifact job 在原生 amd64/arm64 runner 上执行将要上传的 exact quickstart;首份实际运行结果待维护者授权触发后记录。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 61fa9736..35bd1d64 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -514,6 +514,7 @@ | [ADR-0508](./ADR-0508-cross-architecture-local-alpha-milestone-closure.md) | 跨架构 Local Alpha 里程碑闭合 | Accepted(首份实际 milestone artifact 待维护者授权) | | [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) | | [ADR-0510](./ADR-0510-cross-profile-alpha-stage-delivery-index.md) | 跨 Profile Alpha 阶段交付索引 | Accepted(首份实际 stage index 待维护者授权) | +| [ADR-0511](./ADR-0511-runnable-local-alpha-quickstart.md) | 可直接试运行的 Local Alpha Quickstart | Accepted(首份实际 v3 Trial Kit 待维护者授权) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index df949ae0..43b2c559 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -7,7 +7,7 @@ | 等级 | 面向对象 | 必须通过 | 当前用途 | | --- | --- | --- | --- | | Runtime Engineering Candidate | QingLong 开发者、设备兼容测试者 | 单个常驻镜像的 OS 漏洞策略、SBOM/库存、资源门和生命周期 | 验证 runtime 可加载、可启动;缺少管理制品时不能称用户 Alpha | -| Local Alpha Trial Kit | amd64/arm64 路由器、NAS、单机试用者 | 同源 Application + 短生命周期 operator、fresh setup/Owner/active/stop 完整旅程、SBOM/库存与资源门 | 一个去重 Docker archive 完成隔离 fresh 试运行;不承诺生产升级 | +| Local Alpha Trial Kit | amd64/arm64 路由器、NAS、单机试用者 | 同源 Application + 短生命周期 operator、exact quickstart、fresh setup/Owner/active/stop 完整旅程、SBOM/库存与资源门 | POSIX shell 一条命令从去重 Docker archive 完成隔离 fresh 试运行;不承诺生产升级 | | Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release | | Public Release Set | 生产用户 | 受保护 tag、六镜像 multi-arch digest(Local Application/operator + Cluster 四角色)、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 | @@ -26,7 +26,7 @@ 该本地 archive 不是新的 v2 Local Alpha Trial Kit。它在 ADR-0506 前生成,manifest v1 会无条件写入 `passed`,且 macOS Docker Desktop 因 bind-mount UID 映射无法对 exact 本地 archive 完成 Owner pepper 旅程;原生 CI 证明同源码实现,不自动证明另一个 archive 的 exact image bytes。它因此保留为工程候选,不冒充已获 workflow evidence 的用户 Alpha。 -ADR-0506 现要求 `qinglong/alpha-local-trial-kit@v2` 额外包含 `verification-evidence.json`,绑定显式 `workflow_dispatch` 的 source、workflow SHA/ref、run/attempt、架构和两个 image ID。旧 `e3c05862` runtime-only archive、`2620be05` v1 Trial Kit 与 `4239464a` v1 archive 均为历史工程证据,不能通过 v2 auditor。下一项外部里程碑仍是维护者授权 `produce_alpha_artifacts=true`,由同一次原生 milestone job 生成 exact-image evidence 和双架构可下载 archive。 +ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加了 source-bound `verification-evidence.json`;ADR-0511 进一步把当前格式升级为 `@v3`,新增不依赖宿主 Node.js 的 canonical `quickstart.sh`,把可验证镜像闭合为可执行的 fresh 用户旅程。旧 `e3c05862` runtime-only archive、`2620be05` v1 Trial Kit、`4239464a` v1 archive 与未携带 quickstart 的 v2 均为历史工程证据,不能通过 v3 auditor。下一项外部里程碑仍是维护者授权 `produce_alpha_artifacts=true`,由同一次原生 milestone job 生成并实际执行 exact-image 双架构可下载 archive。 ## 生成 @@ -43,7 +43,8 @@ ADR-0506 现要求 `qinglong/alpha-local-trial-kit@v2` 额外包含 `verificatio Local artifact 含: - 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-.docker.tar`;共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v2` 的 `manifest.json`,通过 `archive/images/sboms/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- schema 为 `qinglong/alpha-local-trial-kit@v3` 的 `manifest.json`,通过 `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; - 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`。 @@ -54,7 +55,21 @@ Stage index 是 `qinglong/alpha-stage-index@v1` 三文件闭包。它重新审 任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。 -## 下载后验证与最小 smoke +## 下载后直接试运行 + +在同架构原生 Linux Docker 主机上进入解压后的 Local artifact 目录,使用一个尚不存在的 +隔离目录: + +```sh +sh quickstart.sh edge /opt/qinglong3-alpha-data +``` + +也可以选择 `standalone` 和自定义容器名。quickstart 会先执行 `SHA256SUMS`,再核对 exact +镜像身份并完成 fresh setup、首 Owner 与 Application active;成功后输出 logs、stop 和 +remove 命令。当前是无外部 listener、AI-excluded 的 headless runtime 阶段成果,不是 +2.x Web UI 的替代品。 + +## 手工验证与最小 smoke 在同架构 Linux Docker 主机上进入解压后的 artifact 目录: diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index ba8abccb..598393f0 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -23,7 +23,7 @@ sha256sum --check SHA256SUMS `manifest.json` 必须满足: -- `schema` 为 `qinglong/alpha-local-trial-kit@v2`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v3`; - `sourceRevision` 是你准备试用的完整 40 位 commit; - `architecture` 与主机相同; - `maturity` 为 `alpha_candidate_not_public_release`。 @@ -40,7 +40,37 @@ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ 任一校验失败都不要加载或运行 archive。 -## 加载与最小 smoke +## 一条命令完成 Fresh 试运行 + +v3 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX +shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全 +隔离的绝对路径: + +```sh +sh quickstart.sh edge /opt/qinglong3-alpha-data +``` + +资源较充足的单节点可以把 `edge` 改为 `standalone`。第三个可选参数用于指定容器名: + +```sh +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` 事件才返回成功。 +Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command 结果保留在 +`results/`;两者都位于 `0700` 私有根内,不会打印 Secret 到终端。 + +成功输出会给出当前容器的 logs、graceful stop 和 remove 命令。停止/删除容器不会自动 +删除数据目录;确认不再需要诊断后由操作者显式删除该 fresh 测试目录。脚本拒绝既有目录, +不能用于升级、迁移或接管 2.x。 + +当前 Application 是无外部 listener、AI-excluded 的 headless Alpha runtime。该试运行可 +验证 3.0 SQLite、Owner authority、调度/插件基础与生命周期,但还不是 2.x Web UI 的 +可替代版本。 + +## 手工加载与最小 smoke 从 `manifest.json.archive.file` 找到 archive 后加载: @@ -66,7 +96,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 中验证。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 +完整 fresh setup、首 Owner ceremony、Application active、SIGTERM drain、SQLite integrity 和原生 cancellation 必须在 `verification-evidence.json` 指向的同架构 milestone job 中验证。v3 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 的容量承诺。 diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index 8626a8c1..f43fbf1a 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -10,18 +10,24 @@ 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@v2'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v3'; const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v1'; +const QUICKSTART_TEMPLATE = path.join( + DEFAULT_ROOT, + 'scripts/templates/ql3-local-alpha-quickstart.sh', +); const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const ARCHIVE_MIN_BYTES = 1024; const MAX_JSON_BYTES = 4 * 1024 * 1024; const MAX_README_BYTES = 512 * 1024; +const MAX_QUICKSTART_BYTES = 256 * 1024; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; const REVISION_PATTERN = /^[0-9a-f]{40}$/u; const FILES = Object.freeze({ applicationSbom: 'qinglong3-local-application.cdx.json', operatorSbom: 'qinglong3-local-operator.cdx.json', verificationEvidence: 'verification-evidence.json', + quickstart: 'quickstart.sh', readme: 'README.md', manifest: 'manifest.json', checksums: 'SHA256SUMS', @@ -148,9 +154,7 @@ function saveDockerImages(images, archivePath) { function validateImageReference(value, label) { if ( typeof value !== 'string' || - value.length < 3 || - value.length > 256 || - /[\s\0]/u.test(value) + !/^[A-Za-z0-9][A-Za-z0-9._:/@-]{2,255}$/u.test(value) ) { fail(`${label} image reference is invalid`); } @@ -383,6 +387,34 @@ function archiveName(architecture) { return `qinglong3-local-trial-kit-${architecture}.docker.tar`; } +function renderQuickstart(identity) { + const template = fs.readFileSync( + assertCanonicalFile( + QUICKSTART_TEMPLATE, + MAX_QUICKSTART_BYTES, + 'quickstart template', + ), + 'utf8', + ); + const replacements = Object.freeze({ + '@@APPLICATION_IMAGE@@': identity.images.application.reference, + '@@APPLICATION_ID@@': identity.images.application.id, + '@@OPERATOR_IMAGE@@': identity.images.operator.reference, + '@@OPERATOR_ID@@': identity.images.operator.id, + '@@ARCHITECTURE@@': identity.architecture, + '@@SOURCE_REVISION@@': identity.sourceRevision, + '@@ARCHIVE@@': identity.archive.file, + }); + let rendered = template; + for (const [token, value] of Object.entries(replacements)) { + rendered = rendered.replaceAll(token, value); + } + if (/@@[A-Z_]+@@/u.test(rendered)) { + fail('quickstart template contains an unresolved token'); + } + return rendered; +} + function fileRecord(bundleRoot, name) { const filePath = path.join(bundleRoot, name); const stat = fs.lstatSync(filePath); @@ -541,8 +573,19 @@ function createLocalAlphaTrialKit(options, adapters = {}) { normalized.readme, path.join(normalized.outputRoot, FILES.readme), ); + const manifestIdentity = { + sourceRevision: normalized.sourceRevision, + architecture: normalized.architecture, + archive: { file: archive }, + images: { application, operator }, + }; + writeExclusive( + path.join(normalized.outputRoot, FILES.quickstart), + renderQuickstart(manifestIdentity), + 0o700, + ); const manifest = { - schemaVersion: 3, + schemaVersion: 4, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', @@ -555,6 +598,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { application: fileRecord(normalized.outputRoot, FILES.applicationSbom), operator: fileRecord(normalized.outputRoot, FILES.operatorSbom), }, + quickstart: fileRecord(normalized.outputRoot, FILES.quickstart), readme: fileRecord(normalized.outputRoot, FILES.readme), verification: fileRecord( normalized.outputRoot, @@ -570,6 +614,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { FILES.applicationSbom, FILES.operatorSbom, FILES.verificationEvidence, + FILES.quickstart, FILES.readme, FILES.manifest, ]; @@ -637,10 +682,11 @@ function auditLocalAlphaTrialKit(options) { 'archive', 'images', 'sboms', + 'quickstart', 'readme', 'verification', ]) || - manifest.schemaVersion !== 3 || + manifest.schemaVersion !== 4 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || @@ -673,6 +719,19 @@ function auditLocalAlphaTrialKit(options) { FILES.verificationEvidence, bundleRoot, ); + validateFileRecord(manifest.quickstart, FILES.quickstart, bundleRoot); + const expectedQuickstart = renderQuickstart(manifest); + const actualQuickstart = fs.readFileSync( + assertCanonicalFile( + path.join(bundleRoot, FILES.quickstart), + MAX_QUICKSTART_BYTES, + 'quickstart', + ), + 'utf8', + ); + if (actualQuickstart !== expectedQuickstart) { + fail('quickstart differs from the canonical deployment journey'); + } validateFileRecord(manifest.readme, FILES.readme, bundleRoot); validateOfflineSbom( readBoundedJson( @@ -707,6 +766,7 @@ function auditLocalAlphaTrialKit(options) { FILES.applicationSbom, FILES.operatorSbom, FILES.verificationEvidence, + FILES.quickstart, expectedArchive, ].sort(); const actualFiles = fs @@ -726,6 +786,7 @@ function auditLocalAlphaTrialKit(options) { FILES.applicationSbom, FILES.operatorSbom, FILES.verificationEvidence, + FILES.quickstart, FILES.readme, FILES.manifest, ]; @@ -746,6 +807,7 @@ function auditLocalAlphaTrialKit(options) { archiveSha256: manifest.archive.sha256, applicationImageId: manifest.images.application.id, operatorImageId: manifest.images.operator.id, + quickstartSha256: manifest.quickstart.sha256, verificationSha256: manifest.verification.sha256, workflowRunId: verificationEvidence.workflow.runId, workflowRunAttempt: verificationEvidence.workflow.runAttempt, diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs index 4393e9c0..3643649c 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -199,6 +199,9 @@ function auditWorkflow(contents, findings) { '--mode=record-verification', '--mode=create', '--mode=audit', + '/quickstart.sh" \\\n edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}"', + 'docker stop --time 30 "${QUICKSTART_CONTAINER}"', + 'test -s "${QUICKSTART_ROOT}/qinglong3.sqlite"', '--application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', '--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"', @@ -222,6 +225,7 @@ function auditWorkflow(contents, findings) { '--mode=record-verification', '--mode=create', '--mode=audit', + '/quickstart.sh"', 'name: Upload the tested native Local Alpha trial kit', ]) { const index = contents.indexOf(value, cursor + 1); diff --git a/scripts/templates/ql3-local-alpha-quickstart.sh b/scripts/templates/ql3-local-alpha-quickstart.sh new file mode 100644 index 00000000..241dc876 --- /dev/null +++ b/scripts/templates/ql3-local-alpha-quickstart.sh @@ -0,0 +1,162 @@ +#!/bin/sh + +set -eu + +APPLICATION_IMAGE='@@APPLICATION_IMAGE@@' +APPLICATION_ID='@@APPLICATION_ID@@' +OPERATOR_IMAGE='@@OPERATOR_IMAGE@@' +OPERATOR_ID='@@OPERATOR_ID@@' +ARCHITECTURE='@@ARCHITECTURE@@' +SOURCE_REVISION='@@SOURCE_REVISION@@' +ARCHIVE='@@ARCHIVE@@' + +fail() { + printf '%s\n' "QingLong Local Alpha quickstart failed: $*" >&2 + exit 1 +} + +usage() { + printf '%s\n' \ + 'usage: sh quickstart.sh edge|standalone /absolute/new/data-root [container-name]' >&2 + exit 2 +} + +[ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage +profile=$1 +data_root=$2 +container_name=${3:-ql3-alpha-local} + +case "$profile" in + edge) + memory=128m + pids=64 + ;; + standalone) + memory=256m + pids=256 + ;; + *) usage ;; +esac + +case "$data_root" in + /|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/) + fail 'data root must be a canonical absolute path using A-Z, a-z, 0-9, _, ., / or -' + ;; + /*) ;; + *) fail 'data root must be absolute' ;; +esac +case "$container_name" in + ''|[_.-]*|*[!A-Za-z0-9_.-]*) fail 'container name is invalid' ;; +esac + +command -v docker >/dev/null 2>&1 || fail 'docker is required' +command -v sha256sum >/dev/null 2>&1 || fail 'sha256sum is required' +command -v grep >/dev/null 2>&1 || fail 'grep is required' + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +parent=${data_root%/*} +[ -n "$parent" ] || parent=/ +[ -d "$parent" ] || fail 'data root parent does not exist' +parent_real=$(CDPATH= cd -- "$parent" && pwd -P) +[ "$parent_real/${data_root##*/}" = "$data_root" ] || fail 'data root parent is not canonical' +[ ! -e "$data_root" ] || fail 'data root must not already exist' + +(CDPATH= cd -- "$script_dir" && sha256sum --check SHA256SUMS) +docker info >/dev/null 2>&1 || fail 'docker daemon is unavailable' +docker load --input "$script_dir/$ARCHIVE" >/dev/null + +application_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}' "$APPLICATION_IMAGE") +operator_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}|{{index .Config.Labels "io.qinglong.lifecycle"}}|{{index .Config.Labels "io.qinglong.network"}}' "$OPERATOR_IMAGE") +[ "$application_identity" = "$APPLICATION_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION" ] || fail 'application image identity is incompatible' +[ "$operator_identity" = "$OPERATOR_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION|short-lived|none-by-default" ] || fail 'operator image identity is incompatible' + +old_umask=$(umask) +umask 077 +mkdir -m 0700 "$data_root" +for directory in owner-peppers owner-pepper-backup owner-delivery receipts artifacts plugin-staging plugin-activation results; do + mkdir -m 0700 "$data_root/$directory" +done + +cat >"$data_root/setup.json" <"$data_root/owner-provision.json" <"$data_root/owner-challenge.json" <"$data_root/owner-claim.json" <"$data_root/local-application.json" <"$data_root/results/$command_file.result.json" +} + +run_operator setup setup.json +grep -q '"status":"prepared"' "$data_root/results/setup.json.result.json" || fail 'fresh setup did not report prepared' +run_operator owner owner-provision.json +grep -q '"status":"inserted"' "$data_root/results/owner-provision.json.result.json" || fail 'Owner credential provisioning did not report inserted' +run_operator owner owner-challenge.json +grep -q '"status":"inserted"' "$data_root/results/owner-challenge.json.result.json" || fail 'Owner challenge did not report inserted' +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' + +ready=0 +cleanup() { + if [ "$ready" -ne 1 ]; then + docker rm --force "$container_name" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM + +container_id=$(docker run --detach --name "$container_name" \ + --restart unless-stopped --read-only --user "$uid:$gid" --network none \ + --cap-drop ALL --security-opt no-new-privileges \ + --memory "$memory" --memory-swap "$memory" --cpus 0.5 --pids-limit "$pids" \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m \ + --mount "type=bind,src=$data_root,dst=/var/lib/qinglong3" \ + "$APPLICATION_IMAGE" --config /var/lib/qinglong3/local-application.json) +printf '%s\n' "$container_id" >"$data_root/container.id" +chmod 0600 "$data_root/container.id" + +attempt=0 +while [ "$attempt" -lt 45 ]; do + if docker logs "$container_name" 2>&1 | grep -q '"event":"active"'; then + ready=1 + break + fi + running=$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true) + [ "$running" = true ] || fail 'application stopped before becoming active' + attempt=$((attempt + 1)) + sleep 1 +done +[ "$ready" -eq 1 ] || fail 'application did not become active within 45 seconds' +umask "$old_umask" + +printf '%s\n' \ + "QingLong 3.0 Local Alpha is active ($profile, $ARCHITECTURE)." \ + "Data root: $data_root" \ + "Owner deliveries: $data_root/owner-delivery" \ + "Logs: docker logs $container_name" \ + "Stop: docker stop --time 30 $container_name" \ + "Remove container: docker rm $container_name" \ + 'The fresh data root is retained until you remove it explicitly.' diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs index a07fe4e0..9c1fbafa 100644 --- a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs +++ b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs @@ -1,6 +1,7 @@ 'use strict'; const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -10,6 +11,7 @@ const { createLocalAlphaTrialKit, createLocalAlphaTrialKitVerificationEvidence, parseArguments, + sha256File, } = require('../../scripts/ql3-local-alpha-trial-kit-bundle.cjs'); const { createClusterImageSbom, @@ -148,13 +150,23 @@ function adapters(overrides = {}) { 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@v2'); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v3'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); assert.equal(manifest.images.application.architecture, 'arm64'); assert.equal(manifest.images.operator.architecture, 'arm64'); assert.notEqual(manifest.images.application.id, manifest.images.operator.id); assert.equal(manifest.verification.file, 'verification-evidence.json'); + assert.equal(manifest.quickstart.file, 'quickstart.sh'); + const quickstart = path.join(paths.outputRoot, 'quickstart.sh'); + const syntax = spawnSync('sh', ['-n', quickstart], { encoding: 'utf8' }); + assert.equal(syntax.status, 0, syntax.stderr); + const quickstartContents = fs.readFileSync(quickstart, 'utf8'); + assert.match( + quickstartContents, + /QingLong 3\.0 Local Alpha is active \(\$profile, \$ARCHITECTURE\)/, + ); + assert.match(quickstartContents, /qinglong3-local-application:test-arm64/); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); assert.equal(report.compatible, true); assert.equal(report.sourceRevision, revision); @@ -166,6 +178,7 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { 'qinglong3-local-application.cdx.json', 'qinglong3-local-operator.cdx.json', 'qinglong3-local-trial-kit-arm64.docker.tar', + 'quickstart.sh', 'verification-evidence.json', ]); }); @@ -194,7 +207,13 @@ test('fails closed and removes a partial output on incompatible image identity', }); test('offline audit rejects archive, file-set, SBOM and verification mutation', (t) => { - for (const mutation of ['archive', 'extra', 'sbom', 'verification']) { + for (const mutation of [ + 'archive', + 'extra', + 'quickstart', + 'sbom', + 'verification', + ]) { const paths = fixture(t); paths.outputRoot = path.join(paths.fixtureRoot, `bundle-${mutation}`); createLocalAlphaTrialKit(createOptions(paths), adapters()); @@ -208,6 +227,11 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation', ); } else if (mutation === 'extra') { fs.writeFileSync(path.join(paths.outputRoot, 'credential.txt'), 'secret'); + } else if (mutation === 'quickstart') { + fs.appendFileSync( + path.join(paths.outputRoot, 'quickstart.sh'), + '# drift\n', + ); } else if (mutation === 'sbom') { fs.copyFileSync( path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'), @@ -227,6 +251,143 @@ test('offline audit rejects archive, file-set, SBOM and verification mutation', } }); +test('offline audit rejects a rehashed non-canonical quickstart', (t) => { + const paths = fixture(t); + createLocalAlphaTrialKit(createOptions(paths), adapters()); + const quickstart = path.join(paths.outputRoot, 'quickstart.sh'); + fs.appendFileSync(quickstart, '# locally rewritten\n'); + const manifestPath = path.join(paths.outputRoot, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.quickstart = { + file: 'quickstart.sh', + sha256: sha256File(quickstart), + bytes: fs.statSync(quickstart).size, + }; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const checkedFiles = [ + 'qinglong3-local-trial-kit-arm64.docker.tar', + 'qinglong3-local-application.cdx.json', + 'qinglong3-local-operator.cdx.json', + 'verification-evidence.json', + 'quickstart.sh', + 'README.md', + 'manifest.json', + ]; + fs.writeFileSync( + path.join(paths.outputRoot, 'SHA256SUMS'), + `${checkedFiles + .map( + (name) => + `${sha256File(path.join(paths.outputRoot, name)).slice(7)} ${name}`, + ) + .join('\n')}\n`, + ); + assert.throws( + () => auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }), + /quickstart differs from the canonical deployment journey/, + ); +}); + +test('generated quickstart drives the closed fresh Edge journey', (t) => { + const paths = fixture(t); + createLocalAlphaTrialKit(createOptions(paths), adapters()); + const fakeBin = path.join(paths.fixtureRoot, 'fake-bin'); + fs.mkdirSync(fakeBin); + const dockerLog = path.join(paths.fixtureRoot, 'docker.log'); + const fakeDocker = path.join(fakeBin, 'docker'); + fs.writeFileSync( + fakeDocker, + `#!/bin/sh +printf '%s\\n' "$*" >>"$FAKE_DOCKER_LOG" +case "$1:$2" in + info:|load:*) exit 0 ;; + image:inspect) + case "$*" in + *local-application*) printf '%s\\n' 'sha256:${'1'.repeat( + 64, + )}|arm64|65532:65532|${revision}' ;; + *local-operator*) printf '%s\\n' 'sha256:${'2'.repeat( + 64, + )}|arm64|65532:65532|${revision}|short-lived|none-by-default' ;; + *) exit 1 ;; + esac + exit 0 + ;; + logs:*) printf '%s\\n' '{"event":"active"}'; exit 0 ;; + inspect:*) printf '%s\\n' 'true'; exit 0 ;; +esac +case " $* " in + *' --detach '*) printf '%s\\n' 'fake-container-id'; exit 0 ;; + *'/setup.json'*) printf '%s\\n' '{"status":"prepared"}'; exit 0 ;; + *'/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 ;; +esac +exit 1 +`, + { mode: 0o755 }, + ); + const dataRoot = path.join(paths.fixtureRoot, 'quickstart-data'); + const run = spawnSync( + 'sh', + [ + path.join(paths.outputRoot, 'quickstart.sh'), + 'edge', + dataRoot, + 'ql3-alpha-test', + ], + { + encoding: 'utf8', + env: { + ...process.env, + FAKE_DOCKER_LOG: dockerLog, + PATH: `${fakeBin}:${process.env.PATH}`, + }, + }, + ); + assert.equal( + run.status, + 0, + `${run.stderr}\n${run.stdout}\n${fs.readFileSync(dockerLog, 'utf8')}`, + ); + assert.match( + run.stdout, + /QingLong 3\.0 Local Alpha is active \(edge, arm64\)/, + ); + assert.equal( + fs.readFileSync(path.join(dataRoot, 'container.id'), 'utf8'), + 'fake-container-id\n', + ); + assert.equal( + JSON.parse( + fs.readFileSync( + path.join(dataRoot, 'results', 'owner-claim.json.result.json'), + 'utf8', + ), + ).role, + 'owner', + ); + const calls = fs.readFileSync(dockerLog, 'utf8'); + assert.match( + calls, + /load --input .*qinglong3-local-trial-kit-arm64\.docker\.tar/, + ); + assert.match(calls, /--memory 128m --memory-swap 128m/); + assert.match(calls, /--pids-limit 64/); + assert.doesNotMatch(calls, /--network (?!none)/); +}); + +test('create rejects an image reference that could alter the shell journey', (t) => { + const paths = fixture(t); + const options = createOptions(paths); + options.applicationImage = 'qinglong3-local-application:test;unexpected'; + assert.throws( + () => createLocalAlphaTrialKit(options, adapters()), + /application image reference is invalid/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + test('create rejects verification detached from the reviewed workflow', (t) => { const paths = fixture(t); const evidence = JSON.parse(