diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index e82a386a..faa49477 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -23,6 +23,14 @@ on: - local - cluster - all + local_alpha_variant: + description: Select the Local Alpha deployment surface without changing the default headless image + required: true + default: headless + type: choice + options: + - headless + - console permissions: contents: read @@ -384,9 +392,20 @@ jobs: run: >- docker build --file deploy/containers/ql3-local-application/Dockerfile + --target runtime --build-arg SOURCE_REVISION=${{ github.sha }} --tag "${IMAGE}" . + - name: Build the opt-in Local Console image + env: + CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} + run: >- + docker build + --file deploy/containers/ql3-local-application/Dockerfile + --target runtime-console + --build-arg SOURCE_REVISION=${{ github.sha }} + --tag "${CONSOLE_IMAGE}" + . - name: Build the native short-lived Local operator image env: OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} @@ -404,6 +423,9 @@ jobs: node scripts/ql3-image-os-vulnerability-policy.cjs \ --image=local-operator \ --output=${{ runner.temp }}/ql3-local-operator-${{ matrix.image_arch }}.trivyignore.yaml + node scripts/ql3-image-os-vulnerability-policy.cjs \ + --image=local-console \ + --output=${{ runner.temp }}/ql3-local-console-${{ matrix.image_arch }}.trivyignore.yaml - name: Reject unexcepted high or critical local OS vulnerabilities uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: @@ -434,6 +456,21 @@ jobs: timeout: '10m0s' cache: 'false' trivyignores: ${{ runner.temp }}/ql3-local-operator-${{ matrix.image_arch }}.trivyignore.yaml + - name: Reject unexcepted high or critical Local Console OS vulnerabilities + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + version: 'v0.70.0' + image-ref: qinglong3-local-console:ci-${{ matrix.image_arch }} + scanners: 'vuln' + vuln-type: 'os' + severity: 'HIGH,CRITICAL' + ignore-unfixed: 'false' + exit-code: '1' + format: 'table' + hide-progress: 'true' + timeout: '10m0s' + cache: 'false' + trivyignores: ${{ runner.temp }}/ql3-local-console-${{ matrix.image_arch }}.trivyignore.yaml - name: Verify non-root identity and architecture env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} @@ -456,6 +493,17 @@ jobs: echo "unexpected Local operator image contract: ${actual}" >&2 exit 1 fi + - name: Verify the opt-in Local Console identity + env: + CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} + EXPECTED: ${{ matrix.image_arch }} 65532:65532 edge-application-api,standalone-application-api offline-loopback + run: | + set -euo pipefail + actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.profile"}} {{index .Config.Labels "io.qinglong.local.console"}}' "${CONSOLE_IMAGE}")" + if [[ "${actual}" != "${EXPECTED}" ]]; then + echo "unexpected Local Console image contract: ${actual}" >&2 + exit 1 + fi - name: Reconcile the exact production package inventory env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} @@ -484,6 +532,20 @@ jobs: "${OPERATOR_IMAGE}" scripts/ql3-local-operator-image-inventory.cjs --inventory-root=/opt/qinglong/node_modules + - name: Reconcile the opt-in Local Console package inventory + env: + CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} + run: >- + docker run --rm --read-only + --network none + --cap-drop ALL + --security-opt no-new-privileges + --volume "${{ github.workspace }}:/audit:ro" + --workdir /audit + --entrypoint node + "${CONSOLE_IMAGE}" + scripts/ql3-local-console-image-inventory.cjs + --inventory-root=/opt/qinglong/node_modules - name: Generate and reconcile the reviewed CycloneDX SBOM env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} @@ -520,6 +582,24 @@ jobs: scripts/ql3-cluster-image-sbom.cjs \ --image=local-operator \ --inventory-root=/opt/qinglong/node_modules + - name: Generate and reconcile the Local Console CycloneDX SBOM + env: + CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} + run: | + node scripts/ql3-cluster-image-sbom.cjs \ + --image=local-console \ + --output="${{ runner.temp }}/ql3-local-console.cdx.json" + docker run --rm --read-only \ + --network none \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --volume "${{ github.workspace }}:/audit:ro" \ + --workdir /audit \ + --entrypoint node \ + "${CONSOLE_IMAGE}" \ + scripts/ql3-cluster-image-sbom.cjs \ + --image=local-console \ + --inventory-root=/opt/qinglong/node_modules - name: Exercise the entrypoint under the router stress envelope env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} @@ -535,6 +615,21 @@ jobs: --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m "${IMAGE}" --help + - name: Exercise the opt-in Console entrypoint under the router stress envelope + env: + CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} + run: >- + docker run --rm --read-only + --network none + --cap-drop ALL + --security-opt no-new-privileges + --memory=128m + --memory-swap=128m + --cpus=0.5 + --pids-limit=64 + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m + "${CONSOLE_IMAGE}" + --help - name: Exercise the short-lived Local operator entrypoint env: OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} @@ -576,11 +671,23 @@ jobs: node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \ --application-image="${IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \ - --profile=edge + --profile=edge \ + --variant=headless node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \ --application-image="${IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \ - --profile=standalone + --profile=standalone \ + --variant=headless + - name: Run the Local Console loopback journey + env: + IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} + OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} + run: | + node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \ + --application-image="${IMAGE}" \ + --operator-image="${OPERATOR_IMAGE}" \ + --profile=edge \ + --variant=console - name: Run authenticated Local API cancellation through real Linux processes env: QL3_LOCAL_API_CANCELLATION_LIVE: '1' @@ -599,16 +706,27 @@ jobs: - name: Materialize and offline-audit the 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') env: - IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} + HEADLESS_IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} + CONSOLE_IMAGE: qinglong3-local-console:ci-${{ matrix.image_arch }} OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} + TRIAL_VARIANT: ${{ inputs.local_alpha_variant }} run: | set -euo pipefail umask 077 + if [[ "${TRIAL_VARIANT}" == 'console' ]]; then + IMAGE="${CONSOLE_IMAGE}" + APPLICATION_SBOM="${RUNNER_TEMP}/ql3-local-console.cdx.json" + else + IMAGE="${HEADLESS_IMAGE}" + APPLICATION_SBOM="${RUNNER_TEMP}/ql3-local-application.cdx.json" + fi + BUNDLE_ROOT="${RUNNER_TEMP}/ql3-alpha-local-${TRIAL_VARIANT}-${{ matrix.image_arch }}" node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ --mode=record-verification \ --application-image="${IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \ --architecture=${{ matrix.image_arch }} \ + --variant="${TRIAL_VARIANT}" \ --source-revision=${{ github.sha }} \ --repository=${{ github.repository }} \ --workflow-ref="${{ github.workflow_ref }}" \ @@ -623,19 +741,24 @@ jobs: --application-image="${IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \ --architecture=${{ matrix.image_arch }} \ + --variant="${TRIAL_VARIANT}" \ --source-revision=${{ github.sha }} \ - --application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json" \ + --application-sbom="${APPLICATION_SBOM}" \ --operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json" \ --verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json" \ --readme=docs/operations/ql3-local-alpha-trial-kit.md \ - --output="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" + --output="${BUNDLE_ROOT}" 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 }}" + --mode=audit --bundle="${BUNDLE_ROOT}" + QUICKSTART_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-quickstart-${{ matrix.image_arch }}" + QUICKSTART_ROOT="${RUNNER_TEMP}/ql3-alpha-${TRIAL_VARIANT}-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" \ + sh "${BUNDLE_ROOT}/quickstart.sh" \ edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}" + if [[ "${TRIAL_VARIANT}" == 'console' ]]; then + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1:5700/)" = '200' + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1:5700/api/v3/projects/default/tasks)" = '401' + fi docker stop --time 30 "${QUICKSTART_CONTAINER}" docker rm "${QUICKSTART_CONTAINER}" test -s "${QUICKSTART_ROOT}/qinglong3.sqlite" @@ -645,8 +768,8 @@ jobs: 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 with: - name: ql3-alpha-${{ github.sha }}-local-${{ matrix.image_arch }} - path: ${{ runner.temp }}/ql3-alpha-local-${{ matrix.image_arch }} + name: ql3-alpha-${{ github.sha }}-local-${{ inputs.local_alpha_variant }}-${{ matrix.image_arch }} + path: ${{ runner.temp }}/ql3-alpha-local-${{ inputs.local_alpha_variant }}-${{ matrix.image_arch }} if-no-files-found: error retention-days: 30 compression-level: 0 @@ -1777,12 +1900,12 @@ jobs: - name: Download the exact amd64 Local Alpha trial kit uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ql3-alpha-${{ github.sha }}-local-amd64 + name: ql3-alpha-${{ github.sha }}-local-${{ inputs.local_alpha_variant }}-amd64 path: ${{ runner.temp }}/ql3-alpha-local-milestone-input/amd64 - name: Download the exact arm64 Local Alpha trial kit uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ql3-alpha-${{ github.sha }}-local-arm64 + name: ql3-alpha-${{ github.sha }}-local-${{ inputs.local_alpha_variant }}-arm64 path: ${{ runner.temp }}/ql3-alpha-local-milestone-input/arm64 - name: Close both native trial kits into one milestone index run: | @@ -1792,6 +1915,7 @@ jobs: --mode=finalize \ --amd64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/amd64" \ --arm64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/arm64" \ + --variant=${{ inputs.local_alpha_variant }} \ --source-revision=${{ github.sha }} \ --repository=${{ github.repository }} \ --workflow-ref="${{ github.workflow_ref }}" \ @@ -1807,7 +1931,7 @@ jobs: - name: Upload the closed Local Alpha milestone index uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ql3-alpha-${{ github.sha }}-local-milestone + name: ql3-alpha-${{ github.sha }}-local-${{ inputs.local_alpha_variant }}-milestone path: ${{ runner.temp }}/ql3-alpha-local-milestone if-no-files-found: error retention-days: 30 @@ -1830,7 +1954,7 @@ jobs: - name: Download the closed Local Alpha milestone uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ql3-alpha-${{ github.sha }}-local-milestone + name: ql3-alpha-${{ github.sha }}-local-${{ inputs.local_alpha_variant }}-milestone path: ${{ runner.temp }}/ql3-alpha-stage-input/local - name: Download the closed Cluster Alpha milestone uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/deploy/containers/ql3-local-application/Dockerfile b/deploy/containers/ql3-local-application/Dockerfile index 80464793..3c0f99c9 100644 --- a/deploy/containers/ql3-local-application/Dockerfile +++ b/deploy/containers/ql3-local-application/Dockerfile @@ -23,9 +23,11 @@ WORKDIR /workspace COPY packages/ql3-ai packages/ql3-ai COPY packages/ql3-local-admin packages/ql3-local-admin +COPY packages/ql3-local-api packages/ql3-local-api COPY packages/ql3-local-application packages/ql3-local-application COPY packages/ql3-local-command-file packages/ql3-local-command-file COPY packages/ql3-local-execution packages/ql3-local-execution +COPY packages/ql3-local-owner-console packages/ql3-local-owner-console COPY packages/ql3-local-process packages/ql3-local-process COPY packages/ql3-local-secret packages/ql3-local-secret COPY packages/ql3-local-sqlite packages/ql3-local-sqlite @@ -37,12 +39,16 @@ RUN ln -s /opt/qinglong/node_modules node_modules \ /opt/qinglong/node_modules/@qinglong/ai \ && ln -s /workspace/packages/ql3-local-admin \ /opt/qinglong/node_modules/@qinglong/local-admin \ + && ln -s /workspace/packages/ql3-local-api \ + /opt/qinglong/node_modules/@qinglong/local-api \ && ln -s /workspace/packages/ql3-local-application \ /opt/qinglong/node_modules/@qinglong/local-application \ && ln -s /workspace/packages/ql3-local-command-file \ /opt/qinglong/node_modules/@qinglong/local-command-file \ && ln -s /workspace/packages/ql3-local-execution \ /opt/qinglong/node_modules/@qinglong/local-execution \ + && ln -s /workspace/packages/ql3-local-owner-console \ + /opt/qinglong/node_modules/@qinglong/local-owner-console \ && ln -s /workspace/packages/ql3-local-process \ /opt/qinglong/node_modules/@qinglong/local-process \ && ln -s /workspace/packages/ql3-local-secret \ @@ -59,6 +65,8 @@ RUN ln -s /opt/qinglong/node_modules node_modules \ -p packages/ql3-local-process/tsconfig.json \ && /opt/qinglong/node_modules/.bin/tsc \ -p packages/ql3-local-sqlite/tsconfig.json \ + && /opt/qinglong/node_modules/.bin/tsc \ + -p packages/ql3-local-owner-console/tsconfig.json \ && /opt/qinglong/node_modules/.bin/tsc \ -p packages/ql3-ai/tsconfig.json \ && /opt/qinglong/node_modules/.bin/tsc \ @@ -68,7 +76,9 @@ RUN ln -s /opt/qinglong/node_modules node_modules \ && /opt/qinglong/node_modules/.bin/tsc \ -p packages/ql3-local-execution/tsconfig.json \ && /opt/qinglong/node_modules/.bin/tsc \ - -p packages/ql3-local-application/tsconfig.json + -p packages/ql3-local-application/tsconfig.json \ + && /opt/qinglong/node_modules/.bin/tsc \ + -p packages/ql3-local-api/tsconfig.json FROM runtime-dependency-manifest AS external-dependencies @@ -130,12 +140,85 @@ RUN rm -rf node_modules/.bin \ --exclude=@qinglong/ai \ && rm /tmp/ql3-prune-runtime-artifact.cjs -FROM node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 AS runtime +FROM external-dependencies AS console-assembled + +COPY scripts/ql3-prune-runtime-artifact.cjs /tmp/ql3-prune-runtime-artifact.cjs + +RUN mkdir -p \ + node_modules/@qinglong/runtime-core \ + node_modules/@qinglong/local-admin \ + node_modules/@qinglong/local-api \ + node_modules/@qinglong/local-application \ + node_modules/@qinglong/local-command-file \ + node_modules/@qinglong/local-execution \ + node_modules/@qinglong/local-owner-console \ + node_modules/@qinglong/local-process \ + node_modules/@qinglong/local-secret \ + node_modules/@qinglong/local-sqlite + +COPY --from=workspace /workspace/packages/ql3-runtime-core/package.json \ + node_modules/@qinglong/runtime-core/package.json +COPY --from=workspace /workspace/packages/ql3-runtime-core/dist \ + node_modules/@qinglong/runtime-core/dist +COPY --from=workspace /workspace/packages/ql3-local-admin/package.json \ + node_modules/@qinglong/local-admin/package.json +COPY --from=workspace /workspace/packages/ql3-local-admin/dist \ + node_modules/@qinglong/local-admin/dist +COPY --from=workspace /workspace/packages/ql3-local-api/package.json \ + node_modules/@qinglong/local-api/package.json +COPY --from=workspace /workspace/packages/ql3-local-api/dist \ + node_modules/@qinglong/local-api/dist +COPY --from=workspace /workspace/packages/ql3-local-api/assets \ + node_modules/@qinglong/local-api/assets +COPY --from=workspace /workspace/packages/ql3-local-application/package.json \ + node_modules/@qinglong/local-application/package.json +COPY --from=workspace /workspace/packages/ql3-local-application/dist \ + node_modules/@qinglong/local-application/dist +COPY --from=workspace /workspace/packages/ql3-local-command-file/package.json \ + node_modules/@qinglong/local-command-file/package.json +COPY --from=workspace /workspace/packages/ql3-local-command-file/dist \ + node_modules/@qinglong/local-command-file/dist +COPY --from=workspace /workspace/packages/ql3-local-execution/package.json \ + node_modules/@qinglong/local-execution/package.json +COPY --from=workspace /workspace/packages/ql3-local-execution/dist \ + node_modules/@qinglong/local-execution/dist +COPY --from=workspace /workspace/packages/ql3-local-owner-console/package.json \ + node_modules/@qinglong/local-owner-console/package.json +COPY --from=workspace /workspace/packages/ql3-local-owner-console/dist \ + node_modules/@qinglong/local-owner-console/dist +COPY --from=workspace /workspace/packages/ql3-local-process/package.json \ + node_modules/@qinglong/local-process/package.json +COPY --from=workspace /workspace/packages/ql3-local-process/dist \ + node_modules/@qinglong/local-process/dist +COPY --from=workspace /workspace/packages/ql3-local-secret/package.json \ + node_modules/@qinglong/local-secret/package.json +COPY --from=workspace /workspace/packages/ql3-local-secret/dist \ + node_modules/@qinglong/local-secret/dist +COPY --from=workspace /workspace/packages/ql3-local-sqlite/package.json \ + node_modules/@qinglong/local-sqlite/package.json +COPY --from=workspace /workspace/packages/ql3-local-sqlite/dist \ + node_modules/@qinglong/local-sqlite/dist + +RUN rm -rf node_modules/.bin \ + && node /tmp/ql3-prune-runtime-artifact.cjs node_modules/@qinglong \ + @qinglong/local-api/config \ + @qinglong/local-api/process \ + --exclude=@qinglong/ai \ + --retain-js=local-api/assets/console/console.js \ + && rm /tmp/ql3-prune-runtime-artifact.cjs + +FROM node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 AS runtime-platform RUN apk add --no-cache --upgrade \ libcrypto3=3.5.8-r0 \ libssl3=3.5.8-r0 +ENV NODE_ENV=production + +WORKDIR /opt/qinglong + +FROM runtime-platform AS runtime + ARG SOURCE_REVISION=uncommitted LABEL org.opencontainers.image.title="QingLong 3.0 Local Application" \ @@ -152,12 +235,35 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Local Application" \ io.qinglong.local.sqlite-write-contract="52" \ io.qinglong.local.compose-selection="1" -ENV NODE_ENV=production - -WORKDIR /opt/qinglong - COPY --from=assembled --chown=65532:65532 /opt/qinglong ./ USER 65532:65532 ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js"] + +FROM runtime-platform AS runtime-console + +ARG SOURCE_REVISION=uncommitted + +LABEL org.opencontainers.image.title="QingLong 3.0 Local Console Application" \ + org.opencontainers.image.description="QingLong 3.0 opt-in offline Console for Edge and Standalone" \ + org.opencontainers.image.source="https://github.com/whyour/qinglong" \ + org.opencontainers.image.revision="${SOURCE_REVISION}" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="3.0.0-alpha.2" \ + io.qinglong.profile="edge-application-api,standalone-application-api" \ + io.qinglong.ai="excluded" \ + io.qinglong.local.console="offline-loopback" \ + io.qinglong.local.application-config="2,3,4" \ + io.qinglong.local.sqlite-contract-min="51" \ + io.qinglong.local.sqlite-contract-max="52" \ + io.qinglong.local.sqlite-write-contract="52" \ + io.qinglong.local.compose-selection="1" + +COPY --from=console-assembled --chown=65532:65532 /opt/qinglong ./ + +USER 65532:65532 + +ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-api/dist/cli.js"] + +FROM runtime AS default-runtime diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index ee7714b0..2d9cfe03 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,7 +11,9 @@ 最新增量证据(2026-08-28): -- D-417/ADR-0512(已实现,Console Trial Kit 交付待后续切片):开发约二十天后的阶段产物从“可直接启动的 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 和显式确认工作态。该切片形成可构建、可运行的 source/application-api 阶段制品,但当前 v3 Trial Kit 尚未携带 Console;把 Console image/quickstart 变成路由/NAS 可下载物、固定低性能物理 Edge 容量数据和 public release 仍是后续门。 +- 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 仍是后续门。 - 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`。 diff --git a/docs/adr/ADR-0512-bounded-offline-local-web-console.md b/docs/adr/ADR-0512-bounded-offline-local-web-console.md index 34ce0e1b..f8f01fc2 100644 --- a/docs/adr/ADR-0512-bounded-offline-local-web-console.md +++ b/docs/adr/ADR-0512-bounded-offline-local-web-console.md @@ -66,7 +66,7 @@ D-416 的 Local Trial Kit 和基础 `edge|standalone` Application 仍保持 head - `application-api` Edge/Standalone 制品当前为 3,953,346 / 3,953,490 bytes、467 files、12 packages,远低于 6 MiB/640-file budget;加载闭包为 90 modules,实测 import RSS delta 约 14.0 MiB; - 基础 headless Edge/Standalone、D-416 Trial Kit、Cluster Profile 和 AI/MCP Profile 不因本切片新增 Console 进程或端口; - Console 视觉和交互已在 1440×960 与 390×844 视口验证,工作态覆盖 Task detail、运行确认、Run evidence/Event sequence 和取消入口; -- 当前仍缺少面向部署用户的 Console Docker image/Trial Kit 选择、真实低性能物理 Edge 容量数据,以及受保护 public release。 +- 本 ADR 当时缺少的 Console Docker image/Trial Kit 选择已由 ADR-0513 闭合;真实低性能物理 Edge 容量数据和受保护 public release 仍未完成。 ## 验证 diff --git a/docs/adr/ADR-0513-selectable-local-console-trial-kit.md b/docs/adr/ADR-0513-selectable-local-console-trial-kit.md new file mode 100644 index 00000000..aaa7d483 --- /dev/null +++ b/docs/adr/ADR-0513-selectable-local-console-trial-kit.md @@ -0,0 +1,83 @@ +# ADR-0513:可选择的 Local Console Trial Kit + +- 状态:Accepted +- 日期:2026-08-28 +- 决策:D-418 +- 关联:ADR-0503、ADR-0506、ADR-0508、ADR-0510、ADR-0511、ADR-0512 + +## 背景 + +D-416 已形成可执行的 headless Local Alpha Trial Kit,D-417 已形成有界、离线、loopback-only 的 Local Web Console,但部署用户仍不能下载一套同时包含 Console、短生命周期管理 authority 和 canonical quickstart 的闭合产物。开发约二十天后,阶段成果必须能够由 amd64/arm64 的路由器、NAS 或单节点用户下载、验真、启动和回退,而不能只停留在源码包或单元测试。 + +同时,Console 不应增加默认低配设备的镜像体积、listener 或稳态资源成本;也不能为了容器访问而把 Local API 从 `127.0.0.1` 放宽为 LAN/public listener。 + +## 决策 + +### 1. Local Trial Kit 提供两个互斥变体 + +显式 artifact run 新增 `local_alpha_variant=headless|console`,默认 `headless`。一次授权运行只生成所选变体的 amd64/arm64 Trial Kit 和对应 Local milestone,不把两套 Application 镜像塞入同一个 archive: + +- `headless`:沿用最小 Local Application,稳态无 listener,适合低配路由器/NAS; +- `console`:使用独立 `runtime-console` image target,携带 `@qinglong/local-api` 与 `@qinglong/local-owner-console`,适合需要浏览器操作面的 Linux 单节点。 + +两者共享同一个短生命周期 Local operator。默认 Docker build 仍落在 headless runtime,Console 不进入 2.x、Cluster 或基础 Edge/Standalone 闭包。 + +### 2. Console 保持 loopback-only + +Console quickstart 只允许 Linux Docker host,Application 使用 host network,但进程配置仍严格绑定 `127.0.0.1:5700`。这样宿主浏览器可访问 loopback,同时没有把 API 改为 `0.0.0.0`。远程设备必须由操作者建立 SSH tunnel,不得直接向 LAN 或公网暴露端口。 + +### 3. 产物协议显式升级并绑定变体 + +- Trial Kit:`qinglong/alpha-local-trial-kit@v4`,manifest schemaVersion 5; +- verification evidence:`qinglong/alpha-local-trial-kit-verification@v2`; +- Local milestone:`qinglong/alpha-local-milestone@v2`; +- Alpha stage index:`qinglong/alpha-stage-index@v2`。 + +manifest、verification、SBOM、archive 名、artifact 名、milestone 和 stage deployment selection 都必须记录同一个 `variant`。Headless archive 保留兼容文件名;Console 使用 `qinglong3-local-console-trial-kit-.docker.tar`。跨变体复制、混合双架构或用 headless SBOM 冒充 Console 一律失败关闭。 + +### 4. 阶段可用必须有真实运行证据 + +普通 push/PR 构建并扫描两个 image target,复核 exact package inventory/SBOM,并在原生 amd64/arm64 Linux 上执行: + +- headless Edge 与 Standalone fresh setup、首 Owner、active、SIGTERM drain; +- Console Edge fresh setup、首 Owner、loopback 首页 HTTP 200、未认证 API HTTP 401、SIGTERM drain; +- 128 MiB/64 PID Edge 入口约束和 AI-excluded closure。 + +只有显式 `produce_alpha_artifacts=true` 的 workflow 才能生成大 archive;上传前必须从即将上传的目录运行 exact `quickstart.sh`。没有同 run/attempt 的双架构 Local milestone 时,单个 archive 仍只是中间产物。 + +## 被拒绝的替代方案 + +### 一个 archive 同时携带 headless 与 Console + +拒绝。低配用户会为未选择的 UI 支付下载和存储成本,部署选择也难以从 SBOM 和 image identity 中可靠判定。 + +### Console 取代默认 Local Application + +拒绝。它会让所有路由设备承担额外 package、静态资产和 listener,违背按能力付费与默认最小运行时原则。 + +### 将 Local API 改为 `0.0.0.0` + +拒绝。Alpha 尚未关闭 TLS、可信代理、CSRF 和远程会话门;容器便利性不能扩大网络 authority。 + +### 每个变体一次生成两套 artifact + +拒绝。单次显式授权只产生被维护者选择的变体,避免大 archive 存储翻倍,并保持 milestone 语义单一。 + +## 影响 + +- 低配设备默认仍下载和运行 headless Application,Console 对其为零成本; +- Console image 有独立 12-package、6 MiB/640-file 上限、SBOM、OS vulnerability policy 与双架构 live gate; +- Local milestone 和 stage index 升级 schema,旧 v1/v3 索引不会被新 auditor 静默接受; +- 该决策提供 Alpha/Linux 试运行产物,不等于 public release、生产 ingress、HA、签名或 LTS; +- 首份真实可下载双架构产物仍需维护者显式授权 workflow,普通 CI 成功只证明产物链可生成。 + +## 验证 + +- Trial Kit materializer/auditor 覆盖 headless 与 Console 的 archive、SBOM、quickstart 和 mutation 拒绝; +- Local milestone 与 stage index 覆盖变体绑定、双架构隔离和 Console deployment selection; +- Dockerfile audit 固定 headless/Console 两套精确 package closure、Console asset 保留与 loopback label; +- 原生 Linux CI 对两个架构运行 Console HTTP 200/401 和生命周期门; +- 本地聚焦门为 `70/70`,完整 backend 为 `1646 total / 1644 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0,14 档 Local artifact audit 全部 `compatible=true`; +- 本机实际镜像库存为 headless `10 packages / 425 files / 3,638,399 bytes`、Console `12 packages / 455 files / 3,831,208 bytes`,两者均在 128 MiB/64 PID entrypoint 门内且排除 AI; +- macOS Docker Desktop 的 bind mount 不能提供与原生 Linux 等价的 POSIX Owner UID/mode 证据,Console fresh lifecycle 因此在 owner-private directory 门失败关闭;不得把该宿主限制冒充应用通过,最终双架构证据由推送后的原生 Linux CI 关闭; +- GitNexus 变更审计、镜像静态门和 Docker 实物门在提交前执行。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 176a301b..894e400b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -515,7 +515,8 @@ | [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 待维护者授权) | -| [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | Accepted(Console Trial Kit 交付待后续切片) | +| [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | Accepted(Trial Kit 交付已由 ADR-0513 闭合) | +| [ADR-0513](./ADR-0513-selectable-local-console-trial-kit.md) | 可选择的 Local Console Trial Kit | Accepted(首份实际双架构产物待维护者授权) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 50c8a0b9..e305965f 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -15,16 +15,16 @@ 当维护者显式选择 `alpha_artifact_scope=all` 时,还会生成 `Alpha stage index`。它把同一次 run 的 Local/Cluster milestone 交叉绑定,并为 Edge、Standalone、Cluster 给出目标架构的最小 artifact 选择;这是阶段交付导航,不是正式 release catalog。只生成 Local 或 Cluster 时,各自 milestone 仍可独立成立,不制造一个不完整的总索引。 -## 当前阶段实物(2026-08-27) +## 当前阶段实物(2026-08-28) -在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已经形成两条可独立验收的产品线: +在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已把 headless 用户旅程与 opt-in Console 合并为一条可选择的交付链: | 阶段产物 | 当前可用能力 | 仍缺少 | | --- | --- | --- | -| D-416 Local Alpha Trial Kit v3 | POSIX shell + Docker 一条命令完成 checksum、load、fresh setup、首 Owner、headless Application active/stop | 首份远端双架构实际 artifact 仍需维护者授权 `produce_alpha_artifacts=true` | -| D-417 opt-in Local Web Console | `application-api` 制品可在同进程/同 SQLite/loopback listener 上查看 Task/Run/Event 并显式运行或取消;Edge/Standalone 制品审计通过 | 尚未进入 Trial Kit Docker image/quickstart,不能冒充当前路由/NAS 下载物 | +| 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 同样需维护者授权 | -这两个边界共同防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:D-416 已关闭 fresh headless 用户旅程,D-417 已关闭 source/application-api 操作界面,但两者尚未合并成实际可下载的 Console Trial Kit。操作说明见 [Local Web Console](./ql3-local-web-console.md)。 +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)。 提交 `4239464af6937d56528a0a2c573d12329bc7ca55` 已形成最新 owner-private arm64 工程候选: @@ -35,24 +35,24 @@ 该本地 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` 首次增加了 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。 +ADR-0506 的 `qinglong/alpha-local-trial-kit@v2` 首次增加 source-bound verification,ADR-0511 的 `@v3` 增加 canonical quickstart,ADR-0513 的 `@v4` 再把 `headless|console` 变体绑定到 image、SBOM、verification、milestone 和 stage index。旧 runtime-only、v1/v2/v3 bundle 均为历史工程证据,不能通过 v4 auditor。下一项外部里程碑仍是维护者授权 `produce_alpha_artifacts=true`,由同一次原生 milestone job 生成并实际执行所选变体的双架构可下载 archive。 ## 生成 -在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交,设置 `produce_alpha_artifacts=true`,并明确选择 `alpha_artifact_scope=local|cluster|all`。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。 +在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交,设置 `produce_alpha_artifacts=true`,明确选择 `alpha_artifact_scope=local|cluster|all`,并为 Local 选择 `local_alpha_variant=headless|console`(默认 headless)。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。 成功后同一次 run 生成、保留 30 天: -- `ql3-alpha--local-amd64` 与 `ql3-alpha--local-arm64`; -- `ql3-alpha--local-milestone`; +- `ql3-alpha--local--amd64` 与 `ql3-alpha--local--arm64`; +- `ql3-alpha--local--milestone`; - `ql3-alpha--control-`、`control-ai-`、`admin-`、`worker-`。 - `ql3-alpha--cluster-milestone`。 - 仅 `alpha_artifact_scope=all`:`ql3-alpha--stage-index`。 Local artifact 含: -- 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-.docker.tar`;共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v3` 的 `manifest.json`,通过 `archive/images/sboms/quickstart/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID 与文件长度/SHA-256; +- 一个包含所选 Application 与短生命周期 operator 的 archive;headless 为 `qinglong3-local-trial-kit-.docker.tar`,Console 为 `qinglong3-local-console-trial-kit-.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; - 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; @@ -60,7 +60,7 @@ Local artifact 含: Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。 -Stage index 是 `qinglong/alpha-stage-index@v1` 三文件闭包。它重新审计两个 milestone,要求 version/source/workflow SHA/ref/run/attempt 一致,并把路由/NAS 的单 Local Trial Kit 与 Cluster 的 control/admin/worker 最小集、可选 control-ai 写为机器可读选择;它不重复存放任何镜像 archive。 +Local milestone 是 `qinglong/alpha-local-milestone@v2` 三文件闭包,绑定一个 variant 的双架构 Trial Kit。Stage index 是 `qinglong/alpha-stage-index@v2` 三文件闭包;它重新审计两个 milestone,要求 version/source/workflow SHA/ref/run/attempt 一致,并把 Local variant/Profile 与 Cluster 的 control/admin/worker 最小集、可选 control-ai 写为机器可读选择;它不重复存放任何镜像 archive。 任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。 @@ -75,8 +75,8 @@ 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 的替代品。 +remove 命令。Headless 不开放 listener;Console 只在 Linux host 的 `127.0.0.1:5700` +提供 Alpha 操作面,远程访问必须经 SSH tunnel。两者都不是 2.x Web UI 的生产替代品。 ## 手工验证与最小 smoke diff --git a/docs/operations/ql3-alpha-stage-index.md b/docs/operations/ql3-alpha-stage-index.md index 44fe1f00..c3335807 100644 --- a/docs/operations/ql3-alpha-stage-index.md +++ b/docs/operations/ql3-alpha-stage-index.md @@ -6,7 +6,7 @@ | 设备或环境 | 选择 | 稳态组件 | 需要下载 | | --- | --- | --- | --- | -| 低配路由器、NAS、单机 | `deploymentSelections.local` | 仅 Local Application;Operator 只在 setup/upgrade/recovery 短暂运行 | 目标架构的一个 Local Trial Kit | +| 低配路由器、NAS、单机 | `deploymentSelections.local`;默认选择 `headless`,需要浏览器操作面时显式选择 `console` | 仅 Local Application;Operator 只在 setup/upgrade/recovery 短暂运行 | 目标架构、目标 variant 的一个 Local Trial Kit | | 临时 K3s/Kubernetes 集群 | `deploymentSelections.cluster` | control、admin、worker;AI 按需增加 control-ai | 目标架构的三个 required artifact;需要 AI 时再下载 optional artifact | 不得把四个 Cluster 角色部署到低配路由器,也不需要为一台 amd64 主机下载 arm64 归档。索引固定列出十个可选择 artifact,但每个部署者只下载目标 Profile、架构和角色所需的子集。 @@ -15,7 +15,8 @@ 1. 在本目录运行 `sha256sum --check SHA256SUMS`。 2. 检查 `manifest.json`: - - schema 为 `qinglong/alpha-stage-index@v1`; + - schema 为 `qinglong/alpha-stage-index@v2`; + - `deploymentSelections.local.variant` 与 Local milestone、artifact 名和 Profile 一致; - maturity 为 `alpha_stage_delivery_not_public_release`; - source revision、run ID/attempt 是准备验证的显式 workflow run; - `milestones.local` 与 `milestones.cluster` 分别指向同一提交的 milestone artifact。 diff --git a/docs/operations/ql3-local-alpha-milestone.md b/docs/operations/ql3-local-alpha-milestone.md index 0a2bfb31..8a0fa916 100644 --- a/docs/operations/ql3-local-alpha-milestone.md +++ b/docs/operations/ql3-local-alpha-milestone.md @@ -9,7 +9,7 @@ - 不提供公开 GHCR tag、Cosign 签名、GitHub attestation、catalog、生产升级或长期支持承诺; - Cluster/Kubernetes 仍按独立 Integration Candidate 与 Public Release Set 门验收。 -单个架构 artifact 提前上传并不代表 milestone 成功。没有 `ql3-alpha--local-milestone` 索引、索引 workflow 不是成功终态、run/attempt 不一致或索引审计失败时,已有的大归档只能作为失败运行的中间文件,不得交付用户。 +单个架构 artifact 提前上传并不代表 milestone 成功。没有 `ql3-alpha--local--milestone` 索引、索引 workflow 不是成功终态、run/attempt/variant 不一致或索引审计失败时,已有的大归档只能作为失败运行的中间文件,不得交付用户。 ## 选择并验证下载物 @@ -20,7 +20,8 @@ ``` 2. 打开 `manifest.json`,确认: - - `schema` 为 `qinglong/alpha-local-milestone@v1`; + - `schema` 为 `qinglong/alpha-local-milestone@v2`; + - `variant` 为 `headless` 或 `console`,且两个架构记录都使用同一变体; - `sourceRevision` 是准备试用的完整 40 位提交; - `workflow.event` 为 `workflow_dispatch`,`workflow.job` 为 `local-alpha-milestone`; - GitHub Actions 中对应 `runId/runAttempt` 的整条 `QingLong 3.0 CI` 为成功终态; diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 8fb479eb..1fae3300 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -2,7 +2,7 @@ 本目录是绑定一个 QingLong 3.0 源码提交、一个 Linux 架构和一次显式 GitHub milestone run 的阶段试运行套件,不是公开 release 或生产升级承诺。它同时包含常驻 Application 镜像和短生命周期 operator 镜像;两者共享的 OCI layer 只在同一个 Docker archive 中保存一次。 -一套 Trial Kit 只有被同一 run 的 `ql3-alpha--local-milestone` 跨架构索引收录后才是可交付阶段产物。单个矩阵 job 提前上传、另一架构或完整 CI 随后失败时留下的 artifact 只是中间文件。先按 milestone `manifest.json` 选择本机架构并核对本 bundle manifest digest,再执行下述离线验收。 +一套 Trial Kit 只有被同一 run 的 `ql3-alpha--local--milestone` 跨架构索引收录后才是可交付阶段产物。单个矩阵 job 提前上传、另一架构或完整 CI 随后失败时留下的 artifact 只是中间文件。先按 milestone `manifest.json` 选择 `variant` 和本机架构并核对本 bundle manifest digest,再执行下述离线验收。 ## 适用范围 @@ -11,6 +11,8 @@ - fresh、隔离的测试数据目录; - 离线导入、设备兼容验证和 3.0 Alpha 用户旅程验证。 +`headless` 是默认且最小的低配设备变体,不打开端口。`console` 是显式选择的 Linux-only 变体,携带离线 Web Console,并仅通过宿主 `127.0.0.1:5700` 提供操作面。二者是独立 archive,不应同时下载;远程 Console 只允许经 SSH tunnel 访问,不得暴露到 LAN 或公网。 + 不要把它直接用于生产数据、2.x 唯一数据目录或生产 Secret。Cluster/Kubernetes 节点应使用 Cluster Integration Candidate;本套件不包含 PostgreSQL HA、Worker 或 Cluster Admin。 ## 离线验收 @@ -23,7 +25,8 @@ sha256sum --check SHA256SUMS `manifest.json` 必须满足: -- `schema` 为 `qinglong/alpha-local-trial-kit@v3`; +- `schema` 为 `qinglong/alpha-local-trial-kit@v4`; +- `variant` 为 `headless` 或 `console`,并与 milestone、application SBOM 和 artifact 名一致; - `sourceRevision` 是你准备试用的完整 40 位 commit; - `architecture` 与主机相同; - `maturity` 为 `alpha_candidate_not_public_release`。 @@ -42,7 +45,7 @@ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ ## 一条命令完成 Fresh 试运行 -v3 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX +v4 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全 隔离的绝对路径: @@ -66,11 +69,12 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command 删除数据目录;确认不再需要诊断后由操作者显式删除该 fresh 测试目录。脚本拒绝既有目录, 不能用于升级、迁移或接管 2.x。 -当前 Application 是无外部 listener、AI-excluded 的 headless Alpha runtime。该试运行可 -验证 3.0 SQLite、Owner authority、调度/插件基础与生命周期,但还不是 2.x Web UI 的 -可替代版本。D-417 已在 opt-in `application-api` Profile 提供首个 Local Web Console, -但本 v3 Trial Kit 尚未携带该 Profile;不要期待 quickstart 启动后出现 Web 端口。Console -当前使用方式与边界见 [Local Web Console](./ql3-local-web-console.md)。 +Headless Application 是无外部 listener、AI-excluded 的最小 Alpha runtime。Console 变体同样 +AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内仍严格绑定 +`127.0.0.1:5700` 的 Local API 可由宿主浏览器访问。成功后打开 +`http://127.0.0.1:5700/`;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha, +不是 2.x Web UI 的生产替代版本。Console 能力和凭据边界见 +[Local Web Console](./ql3-local-web-console.md)。 ## 手工加载与最小 smoke @@ -78,6 +82,7 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command ```sh docker load --input qinglong3-local-trial-kit-.docker.tar +# Console 变体使用 qinglong3-local-console-trial-kit-.docker.tar ``` 以 manifest 中 `images.application.reference` 和 `images.operator.reference` 为准,分别核对 `docker image inspect` 返回的 image ID。然后执行无网络、只读 smoke: @@ -98,7 +103,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 中验证。v3 artifact job 还必须从将要上传的目录实际执行 `quickstart.sh` 并完成 graceful stop。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 +完整 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 常驻。 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-alpha-stage-index.cjs b/scripts/ql3-alpha-stage-index.cjs index 81c4eaf3..6ba3e5d5 100644 --- a/scripts/ql3-alpha-stage-index.cjs +++ b/scripts/ql3-alpha-stage-index.cjs @@ -12,7 +12,7 @@ const { sha256File } = require('./ql3-local-alpha-trial-kit-bundle.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const SCHEMA = 'qinglong/alpha-stage-index@v1'; +const SCHEMA = 'qinglong/alpha-stage-index@v2'; const FILES = Object.freeze({ readme: 'README.md', manifest: 'manifest.json', @@ -118,8 +118,10 @@ function stageArtifactName(sourceRevision) { return `ql3-alpha-${sourceRevision}-stage-index`; } -function milestoneArtifactName(sourceRevision, product) { - return `ql3-alpha-${sourceRevision}-${product}-milestone`; +function milestoneArtifactName(sourceRevision, product, variant) { + return product === 'local' + ? `ql3-alpha-${sourceRevision}-local-${variant}-milestone` + : `ql3-alpha-${sourceRevision}-cluster-milestone`; } function validateWorkflow(document, sourceRevision) { @@ -189,8 +191,15 @@ function readMilestones(localMilestoneRoot, clusterMilestoneRoot) { function expectedSelections(local, cluster) { return { local: { - profiles: ['edge', 'standalone'], - intent: 'fresh_non_production_trial', + variant: local.variant, + profiles: + local.variant === 'console' + ? ['edge-application-api', 'standalone-application-api'] + : ['edge', 'standalone'], + intent: + local.variant === 'console' + ? 'fresh_loopback_console_non_production_trial' + : 'fresh_non_production_trial', architectures: Object.fromEntries( ARCHITECTURES.map((architecture) => [ architecture, @@ -224,18 +233,19 @@ function expectedSelections(local, cluster) { }; } -function validateMilestoneRecord(record, product, sourceRevision) { +function validateMilestoneRecord(record, product, sourceRevision, variant) { const expectedMaturity = product === 'local' ? 'alpha_candidate_not_public_release' : 'cluster_integration_candidate_not_public_release'; const expectedSchema = product === 'local' - ? 'qinglong/alpha-local-milestone@v1' + ? 'qinglong/alpha-local-milestone@v2' : 'qinglong/alpha-cluster-milestone@v1'; if ( !exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) || - record.artifactName !== milestoneArtifactName(sourceRevision, product) || + record.artifactName !== + milestoneArtifactName(sourceRevision, product, variant) || record.schema !== expectedSchema || record.maturity !== expectedMaturity || !exactKeys(record.manifest, ['file', 'sha256', 'bytes']) || @@ -293,7 +303,7 @@ function auditAlphaStageIndex(options) { 'deploymentSelections', 'readme', ]) || - manifest.schemaVersion !== 1 || + manifest.schemaVersion !== 2 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_stage_delivery_not_public_release' || manifest.product !== 'qinglong3' || @@ -321,11 +331,13 @@ function auditAlphaStageIndex(options) { manifest.milestones.local, 'local', manifest.sourceRevision, + milestones.local.variant, ); validateMilestoneRecord( manifest.milestones.cluster, 'cluster', manifest.sourceRevision, + undefined, ); const milestoneManifests = { local: fileRecord( @@ -365,12 +377,15 @@ function auditAlphaStageIndex(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-stage-index-audit@v1', + schema: 'qinglong/alpha-stage-index-audit@v2', version: manifest.version, sourceRevision: manifest.sourceRevision, workflowRunId: manifest.workflow.runId, workflowRunAttempt: manifest.workflow.runAttempt, - profiles: ['edge', 'standalone', 'cluster'], + profiles: [ + ...manifest.deploymentSelections.local.profiles, + 'cluster', + ], artifactCount: 10, compatible: true, }); @@ -441,7 +456,7 @@ function finalizeAlphaStageIndex(options) { path.join(normalized.outputRoot, FILES.readme), ); const manifest = { - schemaVersion: 1, + schemaVersion: 2, schema: SCHEMA, maturity: 'alpha_stage_delivery_not_public_release', product: 'qinglong3', @@ -461,6 +476,7 @@ function finalizeAlphaStageIndex(options) { artifactName: milestoneArtifactName( normalized.sourceRevision, 'local', + normalized.local.variant, ), schema: normalized.local.schema, maturity: normalized.local.maturity, @@ -473,6 +489,7 @@ function finalizeAlphaStageIndex(options) { artifactName: milestoneArtifactName( normalized.sourceRevision, 'cluster', + undefined, ), schema: normalized.cluster.schema, maturity: normalized.cluster.maturity, @@ -542,7 +559,7 @@ function auditAlphaStageIndexWorkflow(root = DEFAULT_ROOT) { `if: ${condition}`, ' - local-alpha-milestone\n', ' - cluster-alpha-milestone\n', - `name: ql3-alpha-${'${{ github.sha }}'}-local-milestone`, + `name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-milestone`, `name: ql3-alpha-${'${{ github.sha }}'}-cluster-milestone`, 'scripts/ql3-alpha-stage-index.cjs', '--mode=finalize', diff --git a/scripts/ql3-cluster-image-sbom.cjs b/scripts/ql3-cluster-image-sbom.cjs index 3565c7a6..18710f3b 100644 --- a/scripts/ql3-cluster-image-sbom.cjs +++ b/scripts/ql3-cluster-image-sbom.cjs @@ -76,6 +76,30 @@ const IMAGE_PROFILES = Object.freeze({ 'drizzle-orm': '1.0.0-rc.4', }), }), + 'local-console': Object.freeze({ + id: 'local-console', + buildManifestPath: 'deploy/containers/ql3-local-application/package.json', + buildLockPath: 'deploy/containers/ql3-local-application/package-lock.json', + imageManifestPath: + 'deploy/containers/ql3-local-application/runtime-dependencies/package.json', + imageLockPath: + 'deploy/containers/ql3-local-application/runtime-dependencies/package-lock.json', + internalManifestPaths: Object.freeze([ + 'packages/ql3-runtime-core/package.json', + 'packages/ql3-local-admin/package.json', + 'packages/ql3-local-api/package.json', + 'packages/ql3-local-application/package.json', + 'packages/ql3-local-command-file/package.json', + 'packages/ql3-local-execution/package.json', + 'packages/ql3-local-owner-console/package.json', + 'packages/ql3-local-process/package.json', + 'packages/ql3-local-secret/package.json', + 'packages/ql3-local-sqlite/package.json', + ]), + buildOnlyDependencies: Object.freeze({ + 'drizzle-orm': '1.0.0-rc.4', + }), + }), 'local-operator': Object.freeze({ id: 'local-operator', buildManifestPath: 'deploy/containers/ql3-local-operator/package.json', @@ -132,7 +156,7 @@ function resolveImageProfile(value = 'control') { const profile = IMAGE_PROFILES[value]; if (!profile) { throw new Error( - 'image profile must be exactly control, control-ai, admin, local, local-operator or worker', + 'image profile must be exactly control, control-ai, admin, local, local-console, local-operator or worker', ); } return profile; diff --git a/scripts/ql3-image-os-vulnerability-policy.cjs b/scripts/ql3-image-os-vulnerability-policy.cjs index 71360200..232ee50e 100644 --- a/scripts/ql3-image-os-vulnerability-policy.cjs +++ b/scripts/ql3-image-os-vulnerability-policy.cjs @@ -12,6 +12,7 @@ const IMAGES = Object.freeze([ 'control', 'control-ai', 'local', + 'local-console', 'local-operator', 'worker', ]); @@ -109,6 +110,7 @@ function auditImageOsVulnerabilityPolicy( control: 0, 'control-ai': 0, local: 0, + 'local-console': 0, 'local-operator': 0, worker: 0, }), @@ -120,6 +122,7 @@ function auditImageOsVulnerabilityPolicy( control: 0, 'control-ai': 0, local: 0, + 'local-console': 0, 'local-operator': 0, worker: 0, }; diff --git a/scripts/ql3-local-alpha-milestone.cjs b/scripts/ql3-local-alpha-milestone.cjs index 13818bab..dca62a73 100644 --- a/scripts/ql3-local-alpha-milestone.cjs +++ b/scripts/ql3-local-alpha-milestone.cjs @@ -7,11 +7,12 @@ const path = require('node:path'); const { auditLocalAlphaTrialKit, sha256File, + VARIANTS, } = require('./ql3-local-alpha-trial-kit-bundle.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const SCHEMA = 'qinglong/alpha-local-milestone@v1'; +const SCHEMA = 'qinglong/alpha-local-milestone@v2'; const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const FILES = Object.freeze({ readme: 'README.md', @@ -126,8 +127,8 @@ function checksumContents(root, names) { .join('\n')}\n`; } -function artifactName(sourceRevision, architecture) { - return `ql3-alpha-${sourceRevision}-local-${architecture}`; +function artifactName(sourceRevision, architecture, variant = 'headless') { + return `ql3-alpha-${sourceRevision}-local-${variant}-${architecture}`; } function validateIdentity(options) { @@ -150,6 +151,7 @@ function validateFinalizeOptions(options) { const outputRoot = path.resolve(options.outputRoot || ''); const parent = path.dirname(outputRoot); if ( + !VARIANTS.includes(options.variant) || !path.isAbsolute(outputRoot) || fs.existsSync(outputRoot) || fs.realpathSync(parent) !== parent @@ -179,6 +181,7 @@ function validateFinalizeOptions(options) { 'milestone README', ), sourceRevision: options.sourceRevision, + variant: options.variant, repository: options.repository, workflowRef: options.workflowRef, workflowSha: options.workflowSha, @@ -196,12 +199,17 @@ function bundleRecord(options, architecture) { report.architecture !== architecture || report.sourceRevision !== options.sourceRevision || report.workflowRunId !== options.runId || - report.workflowRunAttempt !== options.runAttempt + report.workflowRunAttempt !== options.runAttempt || + report.variant !== options.variant ) { fail(`${architecture} trial kit is detached from the milestone run`); } return Object.freeze({ - artifactName: artifactName(options.sourceRevision, architecture), + artifactName: artifactName( + options.sourceRevision, + architecture, + options.variant, + ), architecture, bundleManifest: fileRecord( path.join(bundleRoot, 'manifest.json'), @@ -226,7 +234,7 @@ function validateArtifactRecord(record, architecture, manifest) { 'verificationSha256', ]) || record.artifactName !== - artifactName(manifest.sourceRevision, architecture) || + artifactName(manifest.sourceRevision, architecture, manifest.variant) || record.architecture !== architecture || !exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) || record.bundleManifest.file !== 'manifest.json' || @@ -298,16 +306,18 @@ function auditLocalAlphaMilestone(options) { 'schema', 'maturity', 'product', + 'variant', 'version', 'sourceRevision', 'workflow', 'artifacts', 'readme', ]) || - manifest.schemaVersion !== 1 || + manifest.schemaVersion !== 2 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || + !VARIANTS.includes(manifest.variant) || typeof manifest.version !== 'string' || manifest.version.length < 3 || manifest.version.length > 64 || @@ -373,9 +383,10 @@ function auditLocalAlphaMilestone(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-milestone-audit@v1', + schema: 'qinglong/alpha-local-milestone-audit@v2', sourceRevision: manifest.sourceRevision, version: manifest.version, + variant: manifest.variant, workflowRunId: manifest.workflow.runId, workflowRunAttempt: manifest.workflow.runAttempt, architectures: [...ARCHITECTURES], @@ -422,10 +433,11 @@ function finalizeLocalAlphaMilestone(options) { path.join(normalized.outputRoot, FILES.readme), ); const manifest = { - schemaVersion: 1, + schemaVersion: 2, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', + variant: normalized.variant, version: [...versions][0], sourceRevision: normalized.sourceRevision, workflow: { @@ -492,6 +504,10 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) { '- local', '- cluster', '- all', + 'local_alpha_variant:', + 'default: headless', + '- headless', + '- console', "github.run_id || 'validation'", "cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }}", ]; @@ -512,12 +528,13 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) { ' name: Finalize the Local Alpha milestone', ' needs:', 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', - `name: ql3-alpha-${'${{ github.sha }}'}-local-amd64`, - `name: ql3-alpha-${'${{ github.sha }}'}-local-arm64`, + `name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-amd64`, + `name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-arm64`, 'scripts/ql3-local-alpha-milestone.cjs', '--mode=finalize', + '--variant=${{ inputs.local_alpha_variant }}', '--mode=audit', - `name: ql3-alpha-${'${{ github.sha }}'}-local-milestone`, + `name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-milestone`, 'retention-days: 30', 'overwrite: false', ]; @@ -587,6 +604,7 @@ function parseArguments(argv) { 'run-attempt', 'run-id', 'source-revision', + 'variant', 'workflow-ref', 'workflow-sha', ]; @@ -605,6 +623,7 @@ function parseArguments(argv) { }, readme: path.resolve(values.readme), sourceRevision: values['source-revision'], + variant: values.variant, repository: values.repository, workflowRef: values['workflow-ref'], workflowSha: values['workflow-sha'], diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs index f43fbf1a..42fc9318 100644 --- a/scripts/ql3-local-alpha-trial-kit-bundle.cjs +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -10,13 +10,14 @@ 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@v3'; -const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v1'; +const SCHEMA = 'qinglong/alpha-local-trial-kit@v4'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v2'; const QUICKSTART_TEMPLATE = path.join( DEFAULT_ROOT, 'scripts/templates/ql3-local-alpha-quickstart.sh', ); const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); +const VARIANTS = Object.freeze(['headless', 'console']); const ARCHIVE_MIN_BYTES = 1024; const MAX_JSON_BYTES = 4 * 1024 * 1024; const MAX_README_BYTES = 512 * 1024; @@ -43,6 +44,13 @@ const VERIFICATION = Object.freeze({ standaloneFreshLifecycle: 'passed', localApiCancellation: 'passed', }); + +function verificationGates(variant) { + return Object.freeze({ + ...VERIFICATION, + consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable', + }); +} const WORKFLOW_IDENTITY = Object.freeze({ repository: 'whyour/qinglong', workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', @@ -162,11 +170,13 @@ function validateImageReference(value, label) { } function normalizeImageInspection(inspection, options) { - const { architecture, reference, revision, role, version } = options; + const { architecture, reference, revision, role, variant, version } = options; const labels = inspection?.Config?.Labels; const expectedTitle = role === 'application' - ? 'QingLong 3.0 Local Application' + ? variant === 'console' + ? 'QingLong 3.0 Local Console Application' + : 'QingLong 3.0 Local Application' : 'QingLong 3.0 Local Operator'; if ( !SHA256_PATTERN.test(inspection?.Id || '') || @@ -183,8 +193,14 @@ function normalizeImageInspection(inspection, options) { } if ( role === 'application' && - (labels?.['io.qinglong.profile'] !== 'edge,standalone' || - labels?.['io.qinglong.ai'] !== 'excluded') + (labels?.['io.qinglong.profile'] !== + (variant === 'console' + ? 'edge-application-api,standalone-application-api' + : 'edge,standalone') || + labels?.['io.qinglong.ai'] !== 'excluded' || + (variant === 'console' + ? labels?.['io.qinglong.local.console'] !== 'offline-loopback' + : labels?.['io.qinglong.local.console'] !== undefined)) ) { fail('application image profile is incompatible'); } @@ -259,12 +275,14 @@ function validateVerificationEvidence(document, expected) { 'version', 'sourceRevision', 'architecture', + 'variant', 'applicationImageId', 'operatorImageId', ]) || document.subject.version !== expected.version || document.subject.sourceRevision !== expected.sourceRevision || document.subject.architecture !== expected.architecture || + document.subject.variant !== expected.variant || document.subject.applicationImageId !== expected.applicationImageId || document.subject.operatorImageId !== expected.operatorImageId || document.subject.applicationImageId === document.subject.operatorImageId || @@ -284,8 +302,9 @@ 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(VERIFICATION)) || - JSON.stringify(document.gates) !== JSON.stringify(VERIFICATION) + !exactKeys(document.gates, Object.keys(verificationGates(expected.variant))) || + JSON.stringify(document.gates) !== + JSON.stringify(verificationGates(expected.variant)) ) { fail('trial kit verification evidence is incompatible'); } @@ -298,6 +317,7 @@ function validateVerificationOptions(options) { const parent = path.dirname(output); if ( !ARCHITECTURES.includes(options.architecture) || + !VARIANTS.includes(options.variant) || !REVISION_PATTERN.test(options.sourceRevision || '') || !path.isAbsolute(output) || fs.existsSync(output) || @@ -316,6 +336,7 @@ function validateVerificationOptions(options) { root, output, architecture: options.architecture, + variant: options.variant, sourceRevision: options.sourceRevision, applicationImage: validateImageReference( options.applicationImage, @@ -343,6 +364,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) { reference: normalized.applicationImage, revision: normalized.sourceRevision, role: 'application', + variant: normalized.variant, version: release.version, }, ); @@ -353,6 +375,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) { reference: normalized.operatorImage, revision: normalized.sourceRevision, role: 'operator', + variant: normalized.variant, version: release.version, }, ); @@ -364,6 +387,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) { version: release.version, sourceRevision: normalized.sourceRevision, architecture: normalized.architecture, + variant: normalized.variant, applicationImageId: application.id, operatorImageId: operator.id, }, @@ -376,15 +400,17 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) { runId: normalized.runId, runAttempt: normalized.runAttempt, }, - gates: { ...VERIFICATION }, + gates: { ...verificationGates(normalized.variant) }, }; validateVerificationEvidence(evidence, evidence.subject); writeExclusive(normalized.output, `${JSON.stringify(evidence, null, 2)}\n`); return evidence; } -function archiveName(architecture) { - return `qinglong3-local-trial-kit-${architecture}.docker.tar`; +function archiveName(architecture, variant = 'headless') { + return variant === 'console' + ? `qinglong3-local-console-trial-kit-${architecture}.docker.tar` + : `qinglong3-local-trial-kit-${architecture}.docker.tar`; } function renderQuickstart(identity) { @@ -404,6 +430,7 @@ function renderQuickstart(identity) { '@@ARCHITECTURE@@': identity.architecture, '@@SOURCE_REVISION@@': identity.sourceRevision, '@@ARCHIVE@@': identity.archive.file, + '@@VARIANT@@': identity.variant, }); let rendered = template; for (const [token, value] of Object.entries(replacements)) { @@ -442,6 +469,7 @@ function validateCreateOptions(options) { const parent = path.dirname(outputRoot); if ( !ARCHITECTURES.includes(options.architecture) || + !VARIANTS.includes(options.variant) || !REVISION_PATTERN.test(options.sourceRevision || '') || !path.isAbsolute(outputRoot) || fs.existsSync(outputRoot) || @@ -453,6 +481,7 @@ function validateCreateOptions(options) { root, outputRoot, architecture: options.architecture, + variant: options.variant, sourceRevision: options.sourceRevision, applicationImage: validateImageReference( options.applicationImage, @@ -501,7 +530,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { ); validateSbom(applicationSbom, { root: normalized.root, - profile: 'local', + profile: normalized.variant === 'console' ? 'local-console' : 'local', version: release.version, }); validateSbom(operatorSbom, { @@ -516,6 +545,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { reference: normalized.applicationImage, revision: normalized.sourceRevision, role: 'application', + variant: normalized.variant, version: release.version, }, ); @@ -526,6 +556,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { reference: normalized.operatorImage, revision: normalized.sourceRevision, role: 'operator', + variant: normalized.variant, version: release.version, }, ); @@ -534,6 +565,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { version: release.version, sourceRevision: normalized.sourceRevision, architecture: normalized.architecture, + variant: normalized.variant, applicationImageId: application.id, operatorImageId: operator.id, }); @@ -542,7 +574,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { try { fs.mkdirSync(normalized.outputRoot, { mode: 0o700 }); created = true; - const archive = archiveName(normalized.architecture); + const archive = archiveName(normalized.architecture, normalized.variant); const archivePath = path.join(normalized.outputRoot, archive); saveImages( [normalized.applicationImage, normalized.operatorImage], @@ -576,6 +608,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) { const manifestIdentity = { sourceRevision: normalized.sourceRevision, architecture: normalized.architecture, + variant: normalized.variant, archive: { file: archive }, images: { application, operator }, }; @@ -585,13 +618,14 @@ function createLocalAlphaTrialKit(options, adapters = {}) { 0o700, ); const manifest = { - schemaVersion: 4, + schemaVersion: 5, schema: SCHEMA, maturity: 'alpha_candidate_not_public_release', product: 'local', version: release.version, sourceRevision: normalized.sourceRevision, architecture: normalized.architecture, + variant: normalized.variant, archive: fileRecord(normalized.outputRoot, archive), images: { application, operator }, sboms: { @@ -679,6 +713,7 @@ function auditLocalAlphaTrialKit(options) { 'version', 'sourceRevision', 'architecture', + 'variant', 'archive', 'images', 'sboms', @@ -686,13 +721,14 @@ function auditLocalAlphaTrialKit(options) { 'readme', 'verification', ]) || - manifest.schemaVersion !== 4 || + manifest.schemaVersion !== 5 || manifest.schema !== SCHEMA || manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.product !== 'local' || typeof manifest.version !== 'string' || !REVISION_PATTERN.test(manifest.sourceRevision || '') || !ARCHITECTURES.includes(manifest.architecture) || + !VARIANTS.includes(manifest.variant) || !exactKeys(manifest.images, ['application', 'operator']) || !exactKeys(manifest.sboms, ['application', 'operator']) ) { @@ -703,7 +739,7 @@ function auditLocalAlphaTrialKit(options) { if (manifest.images.application.id === manifest.images.operator.id) { fail('trial kit images must be distinct'); } - const expectedArchive = archiveName(manifest.architecture); + const expectedArchive = archiveName(manifest.architecture, manifest.variant); validateFileRecord(manifest.archive, expectedArchive, bundleRoot); if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) { fail('Docker archive is unexpectedly small'); @@ -738,7 +774,7 @@ function auditLocalAlphaTrialKit(options) { path.join(bundleRoot, FILES.applicationSbom), 'application SBOM', ), - 'local', + manifest.variant === 'console' ? 'local-console' : 'local', manifest.version, ); validateOfflineSbom( @@ -755,6 +791,7 @@ function auditLocalAlphaTrialKit(options) { version: manifest.version, sourceRevision: manifest.sourceRevision, architecture: manifest.architecture, + variant: manifest.variant, applicationImageId: manifest.images.application.id, operatorImageId: manifest.images.operator.id, }, @@ -800,10 +837,11 @@ function auditLocalAlphaTrialKit(options) { } return Object.freeze({ schemaVersion: 1, - schema: 'qinglong/alpha-local-trial-kit-audit@v1', + schema: 'qinglong/alpha-local-trial-kit-audit@v2', sourceRevision: manifest.sourceRevision, version: manifest.version, architecture: manifest.architecture, + variant: manifest.variant, archiveSha256: manifest.archive.sha256, applicationImageId: manifest.images.application.id, operatorImageId: manifest.images.operator.id, @@ -845,6 +883,7 @@ function parseArguments(argv) { 'run-attempt', 'run-id', 'source-revision', + 'variant', 'workflow-ref', 'workflow-sha', ]; @@ -857,6 +896,7 @@ function parseArguments(argv) { mode: 'record-verification', output: path.resolve(values.output), architecture: values.architecture, + variant: values.variant, sourceRevision: values['source-revision'], applicationImage: values['application-image'], operatorImage: values['operator-image'], @@ -880,6 +920,7 @@ function parseArguments(argv) { 'output', 'readme', 'source-revision', + 'variant', 'verification-evidence', ]; if ( @@ -891,6 +932,7 @@ function parseArguments(argv) { mode: 'create', outputRoot: path.resolve(values.output), architecture: values.architecture, + variant: values.variant, sourceRevision: values['source-revision'], applicationImage: values['application-image'], operatorImage: values['operator-image'], @@ -933,6 +975,7 @@ module.exports = Object.freeze({ SCHEMA, VERIFICATION, VERIFICATION_SCHEMA, + VARIANTS, archiveName, auditLocalAlphaTrialKit, createLocalAlphaTrialKit, diff --git a/scripts/ql3-local-alpha-trial-kit-live-contract.cjs b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs index 45348bb2..0dd8cce5 100644 --- a/scripts/ql3-local-alpha-trial-kit-live-contract.cjs +++ b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs @@ -20,27 +20,30 @@ function fail(message) { function argumentsFrom(argv) { const values = {}; for (const argument of argv) { - const match = /^--(application-image|operator-image|profile)=(.+)$/u.exec( - argument, - ); + const match = + /^--(application-image|operator-image|profile|variant)=(.+)$/u.exec( + argument, + ); if (!match || Object.hasOwn(values, match[1])) fail('arguments are invalid'); values[match[1]] = match[2]; } if ( - Object.keys(values).length !== 3 || + Object.keys(values).length !== 4 || !IMAGE_PATTERN.test(values['application-image'] ?? '') || !IMAGE_PATTERN.test(values['operator-image'] ?? '') || - !['edge', 'standalone'].includes(values.profile) + !['edge', 'standalone'].includes(values.profile) || + !['headless', 'console'].includes(values.variant) ) { fail( - 'usage: --application-image=... --operator-image=... --profile=edge|standalone', + 'usage: --application-image=... --operator-image=... --profile=edge|standalone --variant=headless|console', ); } return Object.freeze({ applicationImage: values['application-image'], operatorImage: values['operator-image'], profile: values.profile, + variant: values.variant, }); } @@ -61,41 +64,54 @@ function docker(args, options = {}) { return result.stdout.trim(); } -function inspectImages(applicationImage, operatorImage) { - const application = docker([ - 'image', - 'inspect', - '--format', - '{{.Id}} {{.Architecture}} {{.Config.User}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}', - applicationImage, - ]).split(' '); - const operator = docker([ - 'image', - 'inspect', - '--format', - '{{.Id}} {{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.lifecycle"}} {{index .Config.Labels "io.qinglong.authority"}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}', - operatorImage, - ]).split(' '); +function inspectImages(applicationImage, operatorImage, variant) { + const application = JSON.parse( + docker(['image', 'inspect', applicationImage]), + )[0]; + const operator = JSON.parse(docker(['image', 'inspect', operatorImage]))[0]; + const applicationLabels = application?.Config?.Labels; + const operatorLabels = operator?.Config?.Labels; + const expectedTitle = + variant === 'console' + ? 'QingLong 3.0 Local Console Application' + : 'QingLong 3.0 Local Application'; + const expectedProfile = + variant === 'console' + ? 'edge-application-api,standalone-application-api' + : 'edge,standalone'; if ( - !/^sha256:[0-9a-f]{64}$/u.test(application[0] ?? '') || - !/^sha256:[0-9a-f]{64}$/u.test(operator[0] ?? '') || - application[1] !== operator[1] || - !['amd64', 'arm64'].includes(application[1]) || - application[2] !== '65532:65532' || - operator[2] !== '65532:65532' || - operator[3] !== 'short-lived' || - operator[4] !== 'local-owner-management' || - !/^[0-9a-f]{40}$/u.test(application[3] ?? '') || - application[3] !== operator[5] || - application[4] !== operator[6] || - !/^3\.0\.0-alpha\.[0-9]+$/u.test(application[4] ?? '') + !/^sha256:[0-9a-f]{64}$/u.test(application?.Id ?? '') || + !/^sha256:[0-9a-f]{64}$/u.test(operator?.Id ?? '') || + application?.Architecture !== operator?.Architecture || + !['amd64', 'arm64'].includes(application?.Architecture) || + application?.Config?.User !== '65532:65532' || + operator?.Config?.User !== '65532:65532' || + applicationLabels?.['org.opencontainers.image.title'] !== expectedTitle || + applicationLabels?.['io.qinglong.profile'] !== expectedProfile || + applicationLabels?.['io.qinglong.ai'] !== 'excluded' || + (variant === 'console' + ? 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' || + !/^[0-9a-f]{40}$/u.test( + applicationLabels?.['org.opencontainers.image.revision'] ?? '', + ) || + applicationLabels?.['org.opencontainers.image.revision'] !== + operatorLabels?.['org.opencontainers.image.revision'] || + applicationLabels?.['org.opencontainers.image.version'] !== + operatorLabels?.['org.opencontainers.image.version'] || + !/^3\.0\.0-alpha\.[0-9]+$/u.test( + applicationLabels?.['org.opencontainers.image.version'] ?? '', + ) ) { fail('image identity, architecture or authority labels drifted'); } return Object.freeze({ - architecture: application[1], - applicationId: application[0], - operatorId: operator[0], + architecture: application.Architecture, + applicationId: application.Id, + operatorId: operator.Id, }); } @@ -326,6 +342,55 @@ function writeApplicationConfig(state) { }, ai: { deployment: 'excluded' }, }); + if (state.variant === 'console') { + 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', + ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers', + listener: { host: '127.0.0.1', port: 5700 }, + }); + } +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function consoleSurfaceContract() { + let lastError; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const root = await fetch('http://127.0.0.1:5700/', { + redirect: 'manual', + signal: AbortSignal.timeout(2_000), + }); + const api = await fetch( + 'http://127.0.0.1:5700/api/v3/projects/default/tasks', + { + redirect: 'manual', + signal: AbortSignal.timeout(2_000), + }, + ); + await root.body?.cancel(); + await api.body?.cancel(); + if (root.status !== 200 || api.status !== 401) { + fail( + `Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`, + ); + } + return Object.freeze({ + listener: '127.0.0.1:5700', + rootStatus: 200, + unauthenticatedApiStatus: 401, + }); + } catch (error) { + lastError = error; + await delay(250); + } + } + throw lastError || new Error('Console listener did not become ready'); } async function runApplication(state) { @@ -344,7 +409,7 @@ async function runApplication(state) { '--user', `${state.uid}:${state.gid}`, '--network', - 'none', + state.variant === 'console' ? 'host' : 'none', '--cap-drop', 'ALL', '--security-opt', @@ -363,13 +428,18 @@ async function runApplication(state) { `${state.root}:/var/lib/qinglong3`, state.applicationImage, '--config', - '/var/lib/qinglong3/local-application.json', + state.variant === 'console' + ? '/var/lib/qinglong3/local-api.json' + : '/var/lib/qinglong3/local-application.json', ], { stdio: ['ignore', 'pipe', 'pipe'] }, ); let stdout = ''; let stderr = ''; let active = false; + let surfaceError; + let surface = Object.freeze({ status: 'not_applicable' }); + let surfacePromise = Promise.resolve(); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk) => { @@ -385,7 +455,17 @@ async function runApplication(state) { }) ) { active = true; - docker(['stop', '--time', '30', name]); + surfacePromise = (async () => { + try { + if (state.variant === 'console') { + surface = await consoleSurfaceContract(); + } + } catch (error) { + surfaceError = error; + } finally { + docker(['stop', '--time', '30', name]); + } + })(); } }); child.stderr.on('data', (chunk) => { @@ -403,6 +483,8 @@ async function runApplication(state) { resolve({ code, signal }); }); }); + await surfacePromise; + if (surfaceError) throw surfaceError; const events = stdout .trim() .split('\n') @@ -426,7 +508,7 @@ async function runApplication(state) { })}`, ); } - return Object.freeze({ active: true, gracefulStop: true }); + return Object.freeze({ active: true, gracefulStop: true, surface }); } finally { spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' }); } @@ -441,7 +523,11 @@ async function main() { fail('a POSIX identity is required'); } const options = argumentsFrom(process.argv.slice(2)); - const images = inspectImages(options.applicationImage, options.operatorImage); + const images = inspectImages( + options.applicationImage, + options.operatorImage, + options.variant, + ); const root = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-trial-')), ); @@ -479,8 +565,9 @@ async function main() { fail('durable SQLite result is invalid'); process.stdout.write( `${JSON.stringify({ - schemaVersion: 1, - schema: 'qinglong/local-alpha-trial-kit-live@v1', + schemaVersion: 2, + schema: 'qinglong/local-alpha-trial-kit-live@v2', + variant: options.variant, profile: options.profile, architecture: images.architecture, images: { diff --git a/scripts/ql3-local-console-image-inventory.cjs b/scripts/ql3-local-console-image-inventory.cjs new file mode 100644 index 00000000..eace86d6 --- /dev/null +++ b/scripts/ql3-local-console-image-inventory.cjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const MAX_FILES = 640; +const MAX_BYTES = 6 * 1024 * 1024; +const EXPECTED_PACKAGES = Object.freeze([ + '@qinglong/local-admin', + '@qinglong/local-api', + '@qinglong/local-application', + '@qinglong/local-command-file', + '@qinglong/local-execution', + '@qinglong/local-owner-console', + '@qinglong/local-process', + '@qinglong/local-secret', + '@qinglong/local-sqlite', + '@qinglong/runtime-core', + 'croner', + 'semver', +]); + +function fail(message) { + throw new Error(`QingLong local Console image inventory failed: ${message}`); +} + +function inventoryRoot(argv) { + if (argv.length !== 1 || !argv[0].startsWith('--inventory-root=')) { + fail('usage: --inventory-root=/absolute/node_modules'); + } + const root = argv[0].slice('--inventory-root='.length); + if ( + !path.isAbsolute(root) || + path.normalize(root) !== root || + root === path.parse(root).root + ) { + fail('inventory root is invalid'); + } + const stat = fs.lstatSync(root); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + fs.realpathSync(root) !== root + ) { + fail('inventory root must be a canonical directory'); + } + return root; +} + +function packageNames(root) { + const result = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + if (!entry.isDirectory() || entry.isSymbolicLink()) { + fail(`unexpected root entry ${entry.name}`); + } + if (!entry.name.startsWith('@')) { + result.push(entry.name); + continue; + } + const scope = path.join(root, entry.name); + for (const child of fs.readdirSync(scope, { withFileTypes: true })) { + if ( + child.name.startsWith('.') || + !child.isDirectory() || + child.isSymbolicLink() + ) { + fail(`unexpected scoped entry ${entry.name}/${child.name}`); + } + result.push(`${entry.name}/${child.name}`); + } + } + return result.sort(); +} + +function usage(root) { + const pending = [root]; + let files = 0; + let bytes = 0; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) fail('runtime inventory contains a symlink'); + if (stat.isDirectory()) { + pending.push(filePath); + continue; + } + if (!stat.isFile()) fail('runtime inventory contains a special file'); + files += 1; + bytes += stat.size; + if (files > MAX_FILES) fail('runtime file budget exceeded'); + if (bytes > MAX_BYTES) fail('runtime byte budget exceeded'); + } + } + return Object.freeze({ files, bytes }); +} + +function main() { + const root = inventoryRoot(process.argv.slice(2)); + const packages = packageNames(root); + if (JSON.stringify(packages) !== JSON.stringify(EXPECTED_PACKAGES)) { + fail(`package closure drifted: ${packages.join(',')}`); + } + for (const packageName of packages) { + const manifest = JSON.parse( + fs.readFileSync(path.join(root, packageName, 'package.json'), 'utf8'), + ); + if ( + manifest.name !== packageName || + typeof manifest.version !== 'string' + ) { + fail(`package identity drifted: ${packageName}`); + } + } + const measured = usage(root); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + image: 'local-console', + packages, + packageCount: packages.length, + files: measured.files, + bytes: measured.bytes, + maxFiles: MAX_FILES, + maxBytes: MAX_BYTES, + ai: 'excluded', + listener: 'loopback-only', + compatible: true, + })}\n`, + ); +} + +try { + main(); +} catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +} diff --git a/scripts/ql3-local-image-audit.cjs b/scripts/ql3-local-image-audit.cjs index 28c0f697..b2d5afc0 100644 --- a/scripts/ql3-local-image-audit.cjs +++ b/scripts/ql3-local-image-audit.cjs @@ -29,6 +29,18 @@ const RUNTIME_DEPENDENCIES = Object.freeze({ }); const BUILD_PACKAGES = Object.freeze([ 'ql3-ai', + 'ql3-local-admin', + 'ql3-local-api', + 'ql3-local-application', + 'ql3-local-command-file', + 'ql3-local-execution', + 'ql3-local-owner-console', + 'ql3-local-process', + 'ql3-local-secret', + 'ql3-local-sqlite', + 'ql3-runtime-core', +]); +const RUNTIME_PACKAGES = Object.freeze([ 'ql3-local-admin', 'ql3-local-application', 'ql3-local-command-file', @@ -38,8 +50,8 @@ const BUILD_PACKAGES = Object.freeze([ 'ql3-local-sqlite', 'ql3-runtime-core', ]); -const RUNTIME_PACKAGES = Object.freeze( - BUILD_PACKAGES.filter((name) => name !== 'ql3-ai'), +const CONSOLE_RUNTIME_PACKAGES = Object.freeze( + [...RUNTIME_PACKAGES, 'ql3-local-api', 'ql3-local-owner-console'].sort(), ); function readJson(filePath) { @@ -174,6 +186,15 @@ function counts(values) { return result; } +function dockerStage(contents, name) { + const marker = new RegExp(`^FROM [^\\n]+ AS ${name}$`, 'm'); + const match = marker.exec(contents); + if (!match) return ''; + const remaining = contents.slice(match.index + match[0].length); + const next = /^FROM [^\n]+ AS [a-z0-9-]+$/m.exec(remaining); + return next ? remaining.slice(0, next.index) : remaining; +} + function auditDockerfile(contents, findings) { const escapedBuildNodeImage = BUILD_NODE_IMAGE.replace( /[.*+?^${}()|[\]\\]/g, @@ -188,7 +209,7 @@ function auditDockerfile(contents, findings) { 'gm', ); const exactRuntimeBasePattern = new RegExp( - `^FROM ${escapedRuntimeNodeImage} AS runtime$`, + `^FROM ${escapedRuntimeNodeImage} AS runtime-platform$`, 'gm', ); if ( @@ -221,8 +242,13 @@ function auditDockerfile(contents, findings) { addFinding(findings, 'UNREVIEWED_RUNTIME_SURFACE'); } + const workspaceStage = dockerStage(contents, 'workspace'); + const assembledStage = dockerStage(contents, 'assembled'); + const consoleAssembledStage = dockerStage(contents, 'console-assembled'); + const runtimeStage = dockerStage(contents, 'runtime'); + const consoleRuntimeStage = dockerStage(contents, 'runtime-console'); const buildCopies = captures( - contents, + workspaceStage, /^COPY packages\/(ql3-[a-z-]+) packages\/\1$/gm, ).sort(); if (!sameJson(buildCopies, [...BUILD_PACKAGES].sort())) { @@ -230,7 +256,7 @@ function auditDockerfile(contents, findings) { } const runtimeCopyCounts = counts( captures( - contents, + assembledStage, /^COPY --from=workspace \/workspace\/packages\/(ql3-[a-z-]+)\/(?:package\.json|dist) /gm, ), ); @@ -244,6 +270,28 @@ function auditDockerfile(contents, findings) { ) { addFinding(findings, 'RUNTIME_INTERNAL_PACKAGE_CLOSURE_DRIFT'); } + const consoleRuntimeCopyCounts = counts( + captures( + consoleAssembledStage, + /^COPY --from=workspace \/workspace\/packages\/(ql3-[a-z-]+)\/(?:package\.json|dist) /gm, + ), + ); + if ( + !sameJson( + sortedObject(consoleRuntimeCopyCounts), + sortedObject( + Object.fromEntries( + CONSOLE_RUNTIME_PACKAGES.map((name) => [name, 2]), + ), + ), + ) || + !consoleAssembledStage.includes( + 'COPY --from=workspace /workspace/packages/ql3-local-api/assets \\\n' + + ' node_modules/@qinglong/local-api/assets', + ) + ) { + addFinding(findings, 'CONSOLE_RUNTIME_INTERNAL_PACKAGE_CLOSURE_DRIFT'); + } if (contents.includes('COPY --from=workspace /workspace/packages/ql3-ai/')) { addFinding(findings, 'AI_PRESENT_IN_RUNTIME_STAGE'); } @@ -264,20 +312,51 @@ function auditDockerfile(contents, findings) { addFinding(findings, 'RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED'); } if ( - !contents.includes('USER 65532:65532') || - !contents.includes( + !consoleAssembledStage.includes( + 'RUN rm -rf node_modules/.bin \\\n' + + ' && node /tmp/ql3-prune-runtime-artifact.cjs node_modules/@qinglong \\\n' + + ' @qinglong/local-api/config \\\n' + + ' @qinglong/local-api/process \\\n' + + ' --exclude=@qinglong/ai \\\n' + + ' --retain-js=local-api/assets/console/console.js \\\n' + + ' && rm /tmp/ql3-prune-runtime-artifact.cjs', + ) + ) { + addFinding(findings, 'CONSOLE_RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED'); + } + if ( + !runtimeStage.includes('USER 65532:65532') || + !runtimeStage.includes( 'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js"]', ) || - !contents.includes('io.qinglong.ai="excluded"') || - !contents.includes('io.qinglong.profile="edge,standalone"') || - !contents.includes('io.qinglong.local.application-config="2,3,4"') || - !contents.includes('io.qinglong.local.sqlite-contract-min="51"') || - !contents.includes('io.qinglong.local.sqlite-contract-max="52"') || - !contents.includes('io.qinglong.local.sqlite-write-contract="52"') || - !contents.includes('io.qinglong.local.compose-selection="1"') + !runtimeStage.includes('io.qinglong.ai="excluded"') || + !runtimeStage.includes('io.qinglong.profile="edge,standalone"') || + !runtimeStage.includes('io.qinglong.local.application-config="2,3,4"') || + !runtimeStage.includes('io.qinglong.local.sqlite-contract-min="51"') || + !runtimeStage.includes('io.qinglong.local.sqlite-contract-max="52"') || + !runtimeStage.includes('io.qinglong.local.sqlite-write-contract="52"') || + !runtimeStage.includes('io.qinglong.local.compose-selection="1"') ) { addFinding(findings, 'RUNTIME_IDENTITY_OR_LABEL_DRIFT'); } + if ( + !consoleRuntimeStage.includes('USER 65532:65532') || + !consoleRuntimeStage.includes( + 'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-api/dist/cli.js"]', + ) || + !consoleRuntimeStage.includes( + 'org.opencontainers.image.title="QingLong 3.0 Local Console Application"', + ) || + !consoleRuntimeStage.includes( + 'io.qinglong.profile="edge-application-api,standalone-application-api"', + ) || + !consoleRuntimeStage.includes( + 'io.qinglong.local.console="offline-loopback"', + ) || + !consoleRuntimeStage.includes('io.qinglong.ai="excluded"') + ) { + addFinding(findings, 'CONSOLE_RUNTIME_IDENTITY_OR_LABEL_DRIFT'); + } } function auditWorkflow(contents, findings) { @@ -295,6 +374,9 @@ function auditWorkflow(contents, findings) { 'pnpm audit:local-image:ql3', 'docker build', '--file deploy/containers/ql3-local-application/Dockerfile', + '--target runtime', + '--target runtime-console', + 'qinglong3-local-console:ci-${{ matrix.image_arch }}', 'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 52 52 1', 'actual="$(docker image inspect --format \'{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}\' "${IMAGE}")"', 'io.qinglong.local.application-config', @@ -309,10 +391,18 @@ function auditWorkflow(contents, findings) { '--memory=128m', '--pids-limit=64', 'scripts/ql3-local-image-inventory.cjs', + 'scripts/ql3-local-console-image-inventory.cjs', '--inventory-root=/opt/qinglong/node_modules', 'node ../../scripts/ql3-build-package-closure.cjs', 'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge', 'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone', + 'node scripts/ql3-local-alpha-trial-kit-live-contract.cjs', + '--variant=headless', + '--variant=console', + '--image=local-console', + 'io.qinglong.local.console', + '--variant="${TRIAL_VARIANT}"', + 'inputs.local_alpha_variant', ]; for (const value of required) { if (!job.includes(value)) { @@ -356,6 +446,14 @@ function auditLocalImageContract(root) { ...Object.keys(RUNTIME_DEPENDENCIES), ].sort(), ), + consoleRuntimePackages: Object.freeze( + [ + ...CONSOLE_RUNTIME_PACKAGES.map( + (name) => `@qinglong/${name.slice(4)}`, + ), + ...Object.keys(RUNTIME_DEPENDENCIES), + ].sort(), + ), findings: Object.freeze(findings), compatible: findings.length === 0, }); diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs index 3643649c..be4537c0 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -199,10 +199,10 @@ function auditWorkflow(contents, findings) { '--mode=record-verification', '--mode=create', '--mode=audit', - '/quickstart.sh" \\\n edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}"', + 'sh "${BUNDLE_ROOT}/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"', + '--application-sbom="${APPLICATION_SBOM}"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', '--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"', '--readme=docs/operations/ql3-local-alpha-trial-kit.md', @@ -213,6 +213,8 @@ function auditWorkflow(contents, findings) { '--job=${{ github.job }}', '--run-id=${{ github.run_id }}', '--run-attempt=${{ github.run_attempt }}', + '--variant="${TRIAL_VARIANT}"', + 'inputs.local_alpha_variant', ]; for (const value of required) { if (!contents.includes(value)) diff --git a/scripts/ql3-prune-runtime-artifact.cjs b/scripts/ql3-prune-runtime-artifact.cjs index 69e6d91b..6133fcb3 100644 --- a/scripts/ql3-prune-runtime-artifact.cjs +++ b/scripts/ql3-prune-runtime-artifact.cjs @@ -760,17 +760,22 @@ if (require.main === module) { try { if (process.argv.length < 4) { fail( - 'usage: ql3-prune-runtime-artifact.cjs node_modules/@qinglong @qinglong/profile-entry [...]', + 'usage: ql3-prune-runtime-artifact.cjs node_modules/@qinglong @qinglong/profile-entry [...] [--exclude=@qinglong/package] [--retain-js=package/path.js]', ); } const arguments = process.argv.slice(3); const report = pruneRuntimeArtifact(process.argv[2], { entrySpecifiers: arguments.filter( - (argument) => !argument.startsWith('--exclude='), + (argument) => + !argument.startsWith('--exclude=') && + !argument.startsWith('--retain-js='), ), excludedInternalPackages: arguments .filter((argument) => argument.startsWith('--exclude=')) .map((argument) => argument.slice('--exclude='.length)), + retainedJavaScriptFiles: arguments + .filter((argument) => argument.startsWith('--retain-js=')) + .map((argument) => argument.slice('--retain-js='.length)), }); process.stdout.write( `${JSON.stringify({ schemaVersion: 1, ...report })}\n`, diff --git a/scripts/ql3-version-transition.cjs b/scripts/ql3-version-transition.cjs index 4a92fafb..c282e517 100644 --- a/scripts/ql3-version-transition.cjs +++ b/scripts/ql3-version-transition.cjs @@ -25,6 +25,12 @@ const CONTAINER_ROOTS = Object.freeze([ 'deploy/containers/ql3-local-application', 'deploy/containers/ql3-worker', ]); +const CONTAINER_DOCKER_VERSION_LABEL_COUNTS = Object.freeze({ + 'deploy/containers/ql3-cluster-control': 1, + 'deploy/containers/ql3-cluster-admin': 1, + 'deploy/containers/ql3-local-application': 2, + 'deploy/containers/ql3-worker': 1, +}); const DEPLOYMENT_ROOTS = Object.freeze([ 'deploy/kubernetes/ql3-cluster', 'deploy/kubernetes/ql3-worker', @@ -257,7 +263,7 @@ function auditReleaseVersionContract(rootInput = DEFAULT_ROOT) { versionOccurrences( dockerfile, `org.opencontainers.image.version=\"${identity.version}\"`, - ) !== 1 + ) !== CONTAINER_DOCKER_VERSION_LABEL_COUNTS[containerRoot] ) { fail(`container Dockerfile release identity drifted: ${containerRoot}`); } diff --git a/scripts/templates/ql3-local-alpha-quickstart.sh b/scripts/templates/ql3-local-alpha-quickstart.sh index 241dc876..439c9bb9 100644 --- a/scripts/templates/ql3-local-alpha-quickstart.sh +++ b/scripts/templates/ql3-local-alpha-quickstart.sh @@ -9,6 +9,7 @@ OPERATOR_ID='@@OPERATOR_ID@@' ARCHITECTURE='@@ARCHITECTURE@@' SOURCE_REVISION='@@SOURCE_REVISION@@' ARCHIVE='@@ARCHIVE@@' +VARIANT='@@VARIANT@@' fail() { printf '%s\n' "QingLong Local Alpha quickstart failed: $*" >&2 @@ -37,6 +38,18 @@ case "$profile" in ;; *) usage ;; esac +case "$VARIANT" in + headless) + network_mode=none + application_config=local-application.json + ;; + console) + [ "$(uname -s)" = Linux ] || fail 'Console variant requires a Linux Docker host' + network_mode=host + application_config=local-api.json + ;; + *) fail 'embedded Trial Kit variant is invalid' ;; +esac case "$data_root" in /|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/) @@ -92,6 +105,11 @@ EOF cat >"$data_root/local-application.json" <"$data_root/local-api.json" <"$data_root/container.id" chmod 0600 "$data_root/container.id" @@ -153,10 +171,15 @@ done umask "$old_umask" printf '%s\n' \ - "QingLong 3.0 Local Alpha is active ($profile, $ARCHITECTURE)." \ + "QingLong 3.0 Local Alpha is active ($VARIANT, $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.' +if [ "$VARIANT" = console ]; then + printf '%s\n' \ + 'Console: http://127.0.0.1:5700/' \ + 'Remote access: create an SSH tunnel to 127.0.0.1:5700; do not expose the port on LAN or the public Internet.' +fi diff --git a/test/back/ql3AlphaStageIndex.test.cjs b/test/back/ql3AlphaStageIndex.test.cjs index 209746e4..1bc0888b 100644 --- a/test/back/ql3AlphaStageIndex.test.cjs +++ b/test/back/ql3AlphaStageIndex.test.cjs @@ -78,18 +78,19 @@ function writeMilestone(directory, manifest) { fs.writeFileSync(path.join(directory, 'SHA256SUMS'), checksums(directory)); } -function localManifest(attempt = runAttempt) { +function localManifest(attempt = runAttempt, variant = 'headless') { return { - schemaVersion: 1, - schema: 'qinglong/alpha-local-milestone@v1', + schemaVersion: 2, + schema: 'qinglong/alpha-local-milestone@v2', maturity: 'alpha_candidate_not_public_release', product: 'local', + variant, version, sourceRevision: revision, workflow: workflow('local-alpha-milestone', attempt), artifacts: { amd64: { - artifactName: `ql3-alpha-${revision}-local-amd64`, + artifactName: `ql3-alpha-${revision}-local-${variant}-amd64`, architecture: 'amd64', bundleManifest: { file: 'manifest.json', @@ -102,7 +103,7 @@ function localManifest(attempt = runAttempt) { verificationSha256: digest('5'), }, arm64: { - artifactName: `ql3-alpha-${revision}-local-arm64`, + artifactName: `ql3-alpha-${revision}-local-${variant}-arm64`, architecture: 'arm64', bundleManifest: { file: 'manifest.json', @@ -163,7 +164,10 @@ function fixture(t, options = {}) { t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); const localMilestoneRoot = path.join(fixtureRoot, 'local'); const clusterMilestoneRoot = path.join(fixtureRoot, 'cluster'); - writeMilestone(localMilestoneRoot, localManifest(options.localAttempt)); + writeMilestone( + localMilestoneRoot, + localManifest(options.localAttempt, options.variant || 'headless'), + ); writeMilestone(clusterMilestoneRoot, clusterManifest(options.clusterAttempt)); const readme = path.join(fixtureRoot, 'README-source.md'); fs.writeFileSync(readme, '# QingLong 3.0 Alpha stage index\n'); @@ -196,11 +200,11 @@ function finalizeOptions(paths) { test('closes Local and Cluster milestones into one deployment-facing stage index', (t) => { const paths = fixture(t); const manifest = finalizeAlphaStageIndex(finalizeOptions(paths)); - assert.equal(manifest.schema, 'qinglong/alpha-stage-index@v1'); + assert.equal(manifest.schema, 'qinglong/alpha-stage-index@v2'); assert.deepEqual(Object.keys(manifest.milestones), ['local', 'cluster']); assert.deepEqual( manifest.deploymentSelections.local.architectures.amd64.requiredArtifacts, - [`ql3-alpha-${revision}-local-amd64`], + [`ql3-alpha-${revision}-local-headless-amd64`], ); assert.deepEqual( manifest.deploymentSelections.cluster.architectures.arm64.requiredArtifacts, @@ -224,6 +228,34 @@ test('closes Local and Cluster milestones into one deployment-facing stage index assert.deepEqual(report.profiles, ['edge', 'standalone', 'cluster']); }); +test('indexes the Console milestone as a distinct loopback deployment selection', (t) => { + const paths = fixture(t, { variant: 'console' }); + const manifest = finalizeAlphaStageIndex(finalizeOptions(paths)); + assert.equal( + manifest.milestones.local.artifactName, + `ql3-alpha-${revision}-local-console-milestone`, + ); + assert.equal(manifest.deploymentSelections.local.variant, 'console'); + assert.deepEqual(manifest.deploymentSelections.local.profiles, [ + 'edge-application-api', + 'standalone-application-api', + ]); + assert.equal( + manifest.deploymentSelections.local.intent, + 'fresh_loopback_console_non_production_trial', + ); + const report = auditAlphaStageIndex({ + stageRoot: paths.outputRoot, + localMilestoneRoot: paths.localMilestoneRoot, + clusterMilestoneRoot: paths.clusterMilestoneRoot, + }); + assert.deepEqual(report.profiles, [ + 'edge-application-api', + 'standalone-application-api', + 'cluster', + ]); +}); + test('rejects Local and Cluster milestones from different workflow attempts', (t) => { const paths = fixture(t, { clusterAttempt: '2' }); assert.throws( diff --git a/test/back/ql3ClusterImageSbom.test.cjs b/test/back/ql3ClusterImageSbom.test.cjs index 5245e505..e8e6f5d4 100644 --- a/test/back/ql3ClusterImageSbom.test.cjs +++ b/test/back/ql3ClusterImageSbom.test.cjs @@ -128,6 +128,40 @@ test('generates the AI-excluded local application image closure', () => { ); }); +test('generates the bounded AI-excluded local Console image closure', () => { + const document = createClusterImageSbom({ root, image: 'local-console' }); + const report = auditClusterImageSbom(document, { + root, + image: 'local-console', + }); + + assert.deepEqual(report, { + image: 'local-console', + root: `pkg:npm/%40qinglong/local-application-image@${version}`, + components: 12, + externalComponents: 2, + internalComponents: 10, + dependencyNodes: 13, + inventoryVerified: false, + }); + assert.equal( + document.components.some( + (component) => component.name === '@qinglong/local-api', + ), + true, + ); + assert.equal( + document.components.some( + (component) => component.name === '@qinglong/local-owner-console', + ), + true, + ); + assert.equal( + document.components.some((component) => component.name === '@qinglong/ai'), + false, + ); +}); + test('generates the headless Worker image runtime closure', () => { const document = createClusterImageSbom({ root, image: 'worker' }); const report = auditClusterImageSbom(document, { diff --git a/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs b/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs index 2a7d9a7c..16a8713c 100644 --- a/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs +++ b/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs @@ -56,6 +56,7 @@ test('accepts the empty fail-closed production exception policy', () => { control: 0, 'control-ai': 0, local: 0, + 'local-console': 0, 'local-operator': 0, worker: 0, }, diff --git a/test/back/ql3LocalAlphaMilestone.test.cjs b/test/back/ql3LocalAlphaMilestone.test.cjs index a396b786..9716337d 100644 --- a/test/back/ql3LocalAlphaMilestone.test.cjs +++ b/test/back/ql3LocalAlphaMilestone.test.cjs @@ -29,7 +29,7 @@ const revision = 'a'.repeat(40); const runId = '33047425710'; const runAttempt = '2'; -function imageInspection(role, architecture, idCharacter) { +function imageInspection(role, architecture, idCharacter, variant = 'headless') { return { Id: `sha256:${idCharacter.repeat(64)}`, Os: 'linux', @@ -39,15 +39,23 @@ function imageInspection(role, architecture, idCharacter) { Labels: { 'org.opencontainers.image.title': role === 'application' - ? 'QingLong 3.0 Local Application' + ? variant === 'console' + ? 'QingLong 3.0 Local Console Application' + : 'QingLong 3.0 Local Application' : 'QingLong 3.0 Local Operator', 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', 'org.opencontainers.image.revision': revision, 'org.opencontainers.image.version': version, ...(role === 'application' ? { - 'io.qinglong.profile': 'edge,standalone', + 'io.qinglong.profile': + variant === 'console' + ? 'edge-application-api,standalone-application-api' + : 'edge,standalone', 'io.qinglong.ai': 'excluded', + ...(variant === 'console' + ? { 'io.qinglong.local.console': 'offline-loopback' } + : {}), } : { 'io.qinglong.lifecycle': 'short-lived', @@ -60,6 +68,7 @@ function imageInspection(role, architecture, idCharacter) { } function createBundle(fixtureRoot, architecture, options = {}) { + const variant = options.variant || 'headless'; const bundleFixture = path.join(fixtureRoot, architecture); fs.mkdirSync(bundleFixture); const applicationSbom = path.join(bundleFixture, 'application.json'); @@ -69,7 +78,12 @@ function createBundle(fixtureRoot, architecture, options = {}) { const outputRoot = path.join(bundleFixture, 'bundle'); fs.writeFileSync( applicationSbom, - `${JSON.stringify(createClusterImageSbom({ root, image: 'local' }))}\n`, + `${JSON.stringify( + createClusterImageSbom({ + root, + image: variant === 'console' ? 'local-console' : 'local', + }), + )}\n`, ); fs.writeFileSync( operatorSbom, @@ -88,7 +102,7 @@ function createBundle(fixtureRoot, architecture, options = {}) { const adapters = { inspectImage(image) { const role = image.includes('operator') ? 'operator' : 'application'; - return imageInspection(role, architecture, characters[role]); + return imageInspection(role, architecture, characters[role], variant); }, saveImages(images, archivePath) { assert.deepEqual(images, [applicationImage, operatorImage]); @@ -104,6 +118,7 @@ function createBundle(fixtureRoot, architecture, options = {}) { root, output: verificationEvidence, architecture, + variant, sourceRevision: revision, applicationImage, operatorImage, @@ -123,6 +138,7 @@ function createBundle(fixtureRoot, architecture, options = {}) { root, outputRoot, architecture, + variant, sourceRevision: revision, applicationImage, operatorImage, @@ -152,9 +168,16 @@ function fixture(t, options = {}) { readme, outputRoot: path.join(fixtureRoot, 'milestone'), bundles: { - amd64: createBundle(fixtureRoot, 'amd64', options.amd64), - arm64: createBundle(fixtureRoot, 'arm64', options.arm64), + amd64: createBundle(fixtureRoot, 'amd64', { + ...options.amd64, + variant: options.variant || 'headless', + }), + arm64: createBundle(fixtureRoot, 'arm64', { + ...options.arm64, + variant: options.variant || 'headless', + }), }, + variant: options.variant || 'headless', }; } @@ -165,6 +188,7 @@ function finalizeOptions(paths) { bundles: paths.bundles, readme: paths.readme, sourceRevision: revision, + variant: paths.variant, repository: 'whyour/qinglong', workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', workflowSha: revision, @@ -177,16 +201,17 @@ function finalizeOptions(paths) { test('finalizes two exact native trial kits into one closed milestone index', (t) => { const paths = fixture(t); const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths)); - assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v1'); + assert.equal(manifest.schema, 'qinglong/alpha-local-milestone@v2'); + assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']); assert.equal( manifest.artifacts.amd64.artifactName, - `ql3-alpha-${revision}-local-amd64`, + `ql3-alpha-${revision}-local-headless-amd64`, ); assert.equal( manifest.artifacts.arm64.artifactName, - `ql3-alpha-${revision}-local-arm64`, + `ql3-alpha-${revision}-local-headless-arm64`, ); assert.notEqual( manifest.artifacts.amd64.archiveSha256, @@ -204,6 +229,25 @@ test('finalizes two exact native trial kits into one closed milestone index', (t assert.deepEqual(report.architectures, ['amd64', 'arm64']); assert.equal(report.workflowRunId, runId); assert.equal(report.workflowRunAttempt, runAttempt); + assert.equal(report.variant, 'headless'); +}); + +test('finalizes Console trial kits as a separately named milestone', (t) => { + const paths = fixture(t, { variant: 'console' }); + const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths)); + assert.equal(manifest.variant, 'console'); + assert.equal( + manifest.artifacts.amd64.artifactName, + `ql3-alpha-${revision}-local-console-amd64`, + ); + assert.equal( + manifest.artifacts.arm64.artifactName, + `ql3-alpha-${revision}-local-console-arm64`, + ); + assert.equal( + auditLocalAlphaMilestone({ milestoneRoot: paths.outputRoot }).variant, + 'console', + ); }); test('rejects a trial kit from another run attempt before publishing', (t) => { diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs index 9c1fbafa..23362918 100644 --- a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs +++ b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs @@ -27,6 +27,7 @@ const revision = 'a'.repeat(40); function imageInspection( role, idCharacter = role === 'application' ? '1' : '2', + variant = 'headless', ) { return { Id: `sha256:${idCharacter.repeat(64)}`, @@ -37,15 +38,23 @@ function imageInspection( Labels: { 'org.opencontainers.image.title': role === 'application' - ? 'QingLong 3.0 Local Application' + ? variant === 'console' + ? 'QingLong 3.0 Local Console Application' + : 'QingLong 3.0 Local Application' : 'QingLong 3.0 Local Operator', 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', 'org.opencontainers.image.revision': revision, 'org.opencontainers.image.version': version, ...(role === 'application' ? { - 'io.qinglong.profile': 'edge,standalone', + 'io.qinglong.profile': + variant === 'console' + ? 'edge-application-api,standalone-application-api' + : 'edge,standalone', 'io.qinglong.ai': 'excluded', + ...(variant === 'console' + ? { 'io.qinglong.local.console': 'offline-loopback' } + : {}), } : { 'io.qinglong.lifecycle': 'short-lived', @@ -57,7 +66,7 @@ function imageInspection( }; } -function fixture(t) { +function fixture(t, variant = 'headless') { const fixtureRoot = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-alpha-bundle-')), ); @@ -71,7 +80,12 @@ function fixture(t) { const readme = path.join(fixtureRoot, 'README-source.md'); fs.writeFileSync( applicationSbom, - `${JSON.stringify(createClusterImageSbom({ root, image: 'local' }))}\n`, + `${JSON.stringify( + createClusterImageSbom({ + root, + image: variant === 'console' ? 'local-console' : 'local', + }), + )}\n`, ); fs.writeFileSync( operatorSbom, @@ -89,8 +103,8 @@ function fixture(t) { outputRoot: path.join(fixtureRoot, 'bundle'), }; createLocalAlphaTrialKitVerificationEvidence( - verificationOptions(paths), - adapters(), + verificationOptions(paths, { variant }), + adapters({}, variant), ); return paths; } @@ -100,6 +114,7 @@ function verificationOptions(paths, overrides = {}) { root, output: paths.verificationEvidence, architecture: 'arm64', + variant: 'headless', sourceRevision: revision, applicationImage: 'qinglong3-local-application:test-arm64', operatorImage: 'qinglong3-local-operator:test-arm64', @@ -114,11 +129,12 @@ function verificationOptions(paths, overrides = {}) { }; } -function createOptions(paths) { +function createOptions(paths, variant = 'headless') { return { root, outputRoot: paths.outputRoot, architecture: 'arm64', + variant, sourceRevision: revision, applicationImage: 'qinglong3-local-application:test-arm64', operatorImage: 'qinglong3-local-operator:test-arm64', @@ -129,12 +145,12 @@ function createOptions(paths) { }; } -function adapters(overrides = {}) { +function adapters(overrides = {}, variant = 'headless') { return { inspectImage(image) { return image.includes('operator') - ? imageInspection('operator') - : imageInspection('application'); + ? imageInspection('operator', '2', variant) + : imageInspection('application', '1', variant); }, saveImages(images, archivePath) { assert.deepEqual(images, [ @@ -150,7 +166,8 @@ 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@v3'); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v4'); + assert.equal(manifest.variant, 'headless'); assert.equal(manifest.sourceRevision, revision); assert.equal(manifest.architecture, 'arm64'); assert.equal(manifest.images.application.architecture, 'arm64'); @@ -164,13 +181,14 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { const quickstartContents = fs.readFileSync(quickstart, 'utf8'); assert.match( quickstartContents, - /QingLong 3\.0 Local Alpha is active \(\$profile, \$ARCHITECTURE\)/, + /QingLong 3\.0 Local Alpha is active \(\$VARIANT, \$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); assert.equal(report.workflowRunId, '32990652047'); + assert.equal(report.variant, 'headless'); assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ 'README.md', 'SHA256SUMS', @@ -183,6 +201,57 @@ test('materializes and offline-audits one closed two-image trial kit', (t) => { ]); }); +test('materializes a distinct loopback Console trial kit without widening the headless archive', (t) => { + const paths = fixture(t, 'console'); + const manifest = createLocalAlphaTrialKit( + createOptions(paths, 'console'), + adapters({}, 'console'), + ); + assert.equal(manifest.variant, 'console'); + const applicationSbom = JSON.parse( + fs.readFileSync( + path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'), + 'utf8', + ), + ); + assert.equal( + applicationSbom.metadata.properties.find( + (property) => property.name === 'qinglong:image-profile', + ).value, + 'local-console', + ); + const verification = JSON.parse( + fs.readFileSync( + path.join(paths.outputRoot, 'verification-evidence.json'), + 'utf8', + ), + ); + assert.equal(verification.gates.consoleLiveJourney, 'passed'); + const quickstartContents = fs.readFileSync( + path.join(paths.outputRoot, 'quickstart.sh'), + 'utf8', + ); + assert.match(quickstartContents, /VARIANT='console'/); + assert.match(quickstartContents, /network_mode=host/); + 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, /do not expose the port on LAN/); + const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); + assert.equal(report.compatible, true); + assert.equal(report.variant, 'console'); + assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ + 'README.md', + 'SHA256SUMS', + 'manifest.json', + 'qinglong3-local-application.cdx.json', + 'qinglong3-local-console-trial-kit-arm64.docker.tar', + 'qinglong3-local-operator.cdx.json', + 'quickstart.sh', + 'verification-evidence.json', + ]); +}); + test('fails closed and removes a partial output on incompatible image identity', (t) => { const paths = fixture(t); const options = createOptions(paths); @@ -352,7 +421,7 @@ exit 1 ); assert.match( run.stdout, - /QingLong 3\.0 Local Alpha is active \(edge, arm64\)/, + /QingLong 3\.0 Local Alpha is active \(headless, edge, arm64\)/, ); assert.equal( fs.readFileSync(path.join(dataRoot, 'container.id'), 'utf8'), @@ -439,6 +508,7 @@ test('CLI grammar is exact and separates create from offline audit', () => { '--application-image=qinglong3-local-application:test-arm64', '--operator-image=qinglong3-local-operator:test-arm64', '--architecture=arm64', + '--variant=headless', `--source-revision=${revision}`, '--repository=whyour/qinglong', '--workflow-ref=whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', diff --git a/test/back/ql3LocalConsoleImageInventory.test.cjs b/test/back/ql3LocalConsoleImageInventory.test.cjs new file mode 100644 index 00000000..95c046ee --- /dev/null +++ b/test/back/ql3LocalConsoleImageInventory.test.cjs @@ -0,0 +1,77 @@ +'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'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '../..'); +const script = path.join(root, 'scripts/ql3-local-console-image-inventory.cjs'); +const packages = [ + '@qinglong/local-admin', + '@qinglong/local-api', + '@qinglong/local-application', + '@qinglong/local-command-file', + '@qinglong/local-execution', + '@qinglong/local-owner-console', + '@qinglong/local-process', + '@qinglong/local-secret', + '@qinglong/local-sqlite', + '@qinglong/runtime-core', + 'croner', + 'semver', +]; + +function inventoryFixture(t) { + const inventoryRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-console-inventory-')), + ); + t.after(() => fs.rmSync(inventoryRoot, { recursive: true, force: true })); + for (const packageName of packages) { + const packageRoot = path.join(inventoryRoot, packageName); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: packageName, + version: '3.0.0-alpha.1', + })}\n`, + ); + } + return inventoryRoot; +} + +function runInventory(inventoryRoot) { + return spawnSync( + process.execPath, + [script, `--inventory-root=${inventoryRoot}`], + { encoding: 'utf8' }, + ); +} + +test('accepts the exact bounded Local Console package closure', (t) => { + const inventoryRoot = inventoryFixture(t); + const run = runInventory(inventoryRoot); + assert.equal(run.status, 0, run.stderr); + const report = JSON.parse(run.stdout); + assert.equal(report.image, 'local-console'); + assert.equal(report.packageCount, 12); + assert.equal(report.ai, 'excluded'); + assert.equal(report.listener, 'loopback-only'); + assert.equal(report.compatible, true); +}); + +test('rejects one unreviewed runtime package', (t) => { + const inventoryRoot = inventoryFixture(t); + const extra = path.join(inventoryRoot, 'unreviewed'); + fs.mkdirSync(extra); + fs.writeFileSync( + path.join(extra, 'package.json'), + '{"name":"unreviewed","version":"1.0.0"}\n', + ); + const run = runInventory(inventoryRoot); + assert.notEqual(run.status, 0); + assert.match(run.stderr, /package closure drifted/); +}); diff --git a/test/back/ql3LocalImageAudit.test.cjs b/test/back/ql3LocalImageAudit.test.cjs index af5422c0..02071670 100644 --- a/test/back/ql3LocalImageAudit.test.cjs +++ b/test/back/ql3LocalImageAudit.test.cjs @@ -63,6 +63,50 @@ test('accepts the exact AI-excluded local application image contract', () => { 'croner', 'semver', ]); + assert.deepEqual(report.consoleRuntimePackages, [ + '@qinglong/local-admin', + '@qinglong/local-api', + '@qinglong/local-application', + '@qinglong/local-command-file', + '@qinglong/local-execution', + '@qinglong/local-owner-console', + '@qinglong/local-process', + '@qinglong/local-secret', + '@qinglong/local-sqlite', + '@qinglong/runtime-core', + 'croner', + 'semver', + ]); +}); + +test('rejects Console assets, loopback identity or retained JavaScript drift', () => { + const current = fixture(); + try { + const dockerfilePath = path.join(current.target, 'Dockerfile'); + const dockerfile = fs + .readFileSync(dockerfilePath, 'utf8') + .replace( + '--retain-js=local-api/assets/console/console.js', + '--retain-js=local-api/assets/console/unreviewed.js', + ) + .replace('io.qinglong.local.console="offline-loopback"', 'io.qinglong.local.console="public"'); + fs.writeFileSync(dockerfilePath, dockerfile); + const report = auditLocalImageContract(current.root); + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => + code === 'CONSOLE_RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED', + ), + ); + assert.ok( + report.findings.some( + ({ code }) => code === 'CONSOLE_RUNTIME_IDENTITY_OR_LABEL_DRIFT', + ), + ); + } finally { + current.close(); + } }); test('rejects a mutable runtime base image', () => { diff --git a/test/back/ql3RuntimeArtifactPruner.test.cjs b/test/back/ql3RuntimeArtifactPruner.test.cjs index b7f89a0d..aaaa4637 100644 --- a/test/back/ql3RuntimeArtifactPruner.test.cjs +++ b/test/back/ql3RuntimeArtifactPruner.test.cjs @@ -1,4 +1,5 @@ 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'); @@ -575,6 +576,37 @@ test('rejects invalid or missing explicit JavaScript assets before mutation', () } }); +test('CLI retains one reviewed JavaScript asset outside dist', () => { + const current = fixture(); + try { + const application = writePackage( + current.root, + 'local-api', + '@qinglong/local-api', + ); + fs.writeFileSync(path.join(application.dist, 'index.js'), "'use strict';\n"); + const assets = path.join(application.packageRoot, 'assets/console'); + fs.mkdirSync(assets, { recursive: true }); + const retained = path.join(assets, 'console.js'); + fs.writeFileSync(retained, "'use strict';\n"); + const run = spawnSync( + process.execPath, + [ + path.resolve(__dirname, '../../scripts/ql3-prune-runtime-artifact.cjs'), + current.root, + '@qinglong/local-api', + '--retain-js=local-api/assets/console/console.js', + ], + { encoding: 'utf8' }, + ); + assert.equal(run.status, 0, run.stderr); + assert.equal(fs.existsSync(retained), true); + assert.equal(JSON.parse(run.stdout).schemaVersion, 1); + } finally { + current.close(); + } +}); + test('rejects symbolic links before making partial changes', () => { const current = fixture(); try { diff --git a/test/back/ql3VersionTransition.test.cjs b/test/back/ql3VersionTransition.test.cjs index 1e44e32e..f100cb77 100644 --- a/test/back/ql3VersionTransition.test.cjs +++ b/test/back/ql3VersionTransition.test.cjs @@ -109,6 +109,25 @@ test('audits one source-derived QingLong 3 release identity', () => { }); }); +test('requires version identity on both headless and Console runtime targets', (t) => { + const fixture = createFixture(t); + const dockerfilePath = path.join( + fixture, + 'deploy/containers/ql3-local-application/Dockerfile', + ); + const dockerfile = fs.readFileSync(dockerfilePath, 'utf8'); + const label = `org.opencontainers.image.version="${SOURCE_VERSION}"`; + assert.equal(dockerfile.split(label).length - 1, 2); + fs.writeFileSync( + dockerfilePath, + dockerfile.replace(label, 'org.opencontainers.image.version="drifted"'), + ); + assert.throws( + () => auditReleaseVersionContract(fixture), + /container Dockerfile release identity drifted/, + ); +}); + test('plans the exact governed version surface without touching legacy 2.x', () => { const plan = createVersionTransitionPlan({ root, @@ -116,7 +135,7 @@ test('plans the exact governed version surface without touching legacy 2.x', () targetVersion: TARGET_VERSION, }); assert.equal(plan.fileCount, 66); - assert.equal(plan.replacementCount, 86); + assert.equal(plan.replacementCount, 87); assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION); assert.equal(plan.legacyRootExcluded, true); assert.equal(