feat(ql3): ship selectable local console trial kit

This commit is contained in:
whyour
2026-08-28 06:50:37 +08:00
parent 84d1a61372
commit c60ab7e48a
31 changed files with 1339 additions and 192 deletions
+139 -15
View File
@@ -23,6 +23,14 @@ on:
- local - local
- cluster - cluster
- all - 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: permissions:
contents: read contents: read
@@ -384,9 +392,20 @@ jobs:
run: >- run: >-
docker build docker build
--file deploy/containers/ql3-local-application/Dockerfile --file deploy/containers/ql3-local-application/Dockerfile
--target runtime
--build-arg SOURCE_REVISION=${{ github.sha }} --build-arg SOURCE_REVISION=${{ github.sha }}
--tag "${IMAGE}" --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 - name: Build the native short-lived Local operator image
env: env:
OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }}
@@ -404,6 +423,9 @@ jobs:
node scripts/ql3-image-os-vulnerability-policy.cjs \ node scripts/ql3-image-os-vulnerability-policy.cjs \
--image=local-operator \ --image=local-operator \
--output=${{ runner.temp }}/ql3-local-operator-${{ matrix.image_arch }}.trivyignore.yaml --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 - name: Reject unexcepted high or critical local OS vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with: with:
@@ -434,6 +456,21 @@ jobs:
timeout: '10m0s' timeout: '10m0s'
cache: 'false' cache: 'false'
trivyignores: ${{ runner.temp }}/ql3-local-operator-${{ matrix.image_arch }}.trivyignore.yaml 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 - name: Verify non-root identity and architecture
env: env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -456,6 +493,17 @@ jobs:
echo "unexpected Local operator image contract: ${actual}" >&2 echo "unexpected Local operator image contract: ${actual}" >&2
exit 1 exit 1
fi 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 - name: Reconcile the exact production package inventory
env: env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -484,6 +532,20 @@ jobs:
"${OPERATOR_IMAGE}" "${OPERATOR_IMAGE}"
scripts/ql3-local-operator-image-inventory.cjs scripts/ql3-local-operator-image-inventory.cjs
--inventory-root=/opt/qinglong/node_modules --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 - name: Generate and reconcile the reviewed CycloneDX SBOM
env: env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -520,6 +582,24 @@ jobs:
scripts/ql3-cluster-image-sbom.cjs \ scripts/ql3-cluster-image-sbom.cjs \
--image=local-operator \ --image=local-operator \
--inventory-root=/opt/qinglong/node_modules --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 - name: Exercise the entrypoint under the router stress envelope
env: env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -535,6 +615,21 @@ jobs:
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m
"${IMAGE}" "${IMAGE}"
--help --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 - name: Exercise the short-lived Local operator entrypoint
env: env:
OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }}
@@ -576,11 +671,23 @@ jobs:
node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \ node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \
--application-image="${IMAGE}" \ --application-image="${IMAGE}" \
--operator-image="${OPERATOR_IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \
--profile=edge --profile=edge \
--variant=headless
node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \ node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \
--application-image="${IMAGE}" \ --application-image="${IMAGE}" \
--operator-image="${OPERATOR_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 - name: Run authenticated Local API cancellation through real Linux processes
env: env:
QL3_LOCAL_API_CANCELLATION_LIVE: '1' QL3_LOCAL_API_CANCELLATION_LIVE: '1'
@@ -599,16 +706,27 @@ jobs:
- name: Materialize and offline-audit the native Local Alpha trial kit - 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') if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all')
env: 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 }} OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }}
TRIAL_VARIANT: ${{ inputs.local_alpha_variant }}
run: | run: |
set -euo pipefail set -euo pipefail
umask 077 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 \ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \
--mode=record-verification \ --mode=record-verification \
--application-image="${IMAGE}" \ --application-image="${IMAGE}" \
--operator-image="${OPERATOR_IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \
--architecture=${{ matrix.image_arch }} \ --architecture=${{ matrix.image_arch }} \
--variant="${TRIAL_VARIANT}" \
--source-revision=${{ github.sha }} \ --source-revision=${{ github.sha }} \
--repository=${{ github.repository }} \ --repository=${{ github.repository }} \
--workflow-ref="${{ github.workflow_ref }}" \ --workflow-ref="${{ github.workflow_ref }}" \
@@ -623,19 +741,24 @@ jobs:
--application-image="${IMAGE}" \ --application-image="${IMAGE}" \
--operator-image="${OPERATOR_IMAGE}" \ --operator-image="${OPERATOR_IMAGE}" \
--architecture=${{ matrix.image_arch }} \ --architecture=${{ matrix.image_arch }} \
--variant="${TRIAL_VARIANT}" \
--source-revision=${{ github.sha }} \ --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" \ --operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json" \
--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json" \ --verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json" \
--readme=docs/operations/ql3-local-alpha-trial-kit.md \ --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 \ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \
--mode=audit --bundle="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" --mode=audit --bundle="${BUNDLE_ROOT}"
QUICKSTART_CONTAINER="ql3-alpha-quickstart-${{ matrix.image_arch }}" QUICKSTART_CONTAINER="ql3-alpha-${TRIAL_VARIANT}-quickstart-${{ matrix.image_arch }}"
QUICKSTART_ROOT="${RUNNER_TEMP}/ql3-alpha-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 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}" 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 stop --time 30 "${QUICKSTART_CONTAINER}"
docker rm "${QUICKSTART_CONTAINER}" docker rm "${QUICKSTART_CONTAINER}"
test -s "${QUICKSTART_ROOT}/qinglong3.sqlite" 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') 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 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: ql3-alpha-${{ github.sha }}-local-${{ matrix.image_arch }} name: ql3-alpha-${{ github.sha }}-local-${{ inputs.local_alpha_variant }}-${{ matrix.image_arch }}
path: ${{ runner.temp }}/ql3-alpha-local-${{ matrix.image_arch }} path: ${{ runner.temp }}/ql3-alpha-local-${{ inputs.local_alpha_variant }}-${{ matrix.image_arch }}
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
compression-level: 0 compression-level: 0
@@ -1777,12 +1900,12 @@ jobs:
- name: Download the exact amd64 Local Alpha trial kit - name: Download the exact amd64 Local Alpha trial kit
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: 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 path: ${{ runner.temp }}/ql3-alpha-local-milestone-input/amd64
- name: Download the exact arm64 Local Alpha trial kit - name: Download the exact arm64 Local Alpha trial kit
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: 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 path: ${{ runner.temp }}/ql3-alpha-local-milestone-input/arm64
- name: Close both native trial kits into one milestone index - name: Close both native trial kits into one milestone index
run: | run: |
@@ -1792,6 +1915,7 @@ jobs:
--mode=finalize \ --mode=finalize \
--amd64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/amd64" \ --amd64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/amd64" \
--arm64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/arm64" \ --arm64-bundle="${RUNNER_TEMP}/ql3-alpha-local-milestone-input/arm64" \
--variant=${{ inputs.local_alpha_variant }} \
--source-revision=${{ github.sha }} \ --source-revision=${{ github.sha }} \
--repository=${{ github.repository }} \ --repository=${{ github.repository }} \
--workflow-ref="${{ github.workflow_ref }}" \ --workflow-ref="${{ github.workflow_ref }}" \
@@ -1807,7 +1931,7 @@ jobs:
- name: Upload the closed Local Alpha milestone index - name: Upload the closed Local Alpha milestone index
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: 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 path: ${{ runner.temp }}/ql3-alpha-local-milestone
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -1830,7 +1954,7 @@ jobs:
- name: Download the closed Local Alpha milestone - name: Download the closed Local Alpha milestone
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: 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 path: ${{ runner.temp }}/ql3-alpha-stage-input/local
- name: Download the closed Cluster Alpha milestone - name: Download the closed Cluster Alpha milestone
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -23,9 +23,11 @@ WORKDIR /workspace
COPY packages/ql3-ai packages/ql3-ai COPY packages/ql3-ai packages/ql3-ai
COPY packages/ql3-local-admin packages/ql3-local-admin 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-application packages/ql3-local-application
COPY packages/ql3-local-command-file packages/ql3-local-command-file COPY packages/ql3-local-command-file packages/ql3-local-command-file
COPY packages/ql3-local-execution packages/ql3-local-execution 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-process packages/ql3-local-process
COPY packages/ql3-local-secret packages/ql3-local-secret COPY packages/ql3-local-secret packages/ql3-local-secret
COPY packages/ql3-local-sqlite packages/ql3-local-sqlite 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 \ /opt/qinglong/node_modules/@qinglong/ai \
&& ln -s /workspace/packages/ql3-local-admin \ && ln -s /workspace/packages/ql3-local-admin \
/opt/qinglong/node_modules/@qinglong/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 \ && ln -s /workspace/packages/ql3-local-application \
/opt/qinglong/node_modules/@qinglong/local-application \ /opt/qinglong/node_modules/@qinglong/local-application \
&& ln -s /workspace/packages/ql3-local-command-file \ && ln -s /workspace/packages/ql3-local-command-file \
/opt/qinglong/node_modules/@qinglong/local-command-file \ /opt/qinglong/node_modules/@qinglong/local-command-file \
&& ln -s /workspace/packages/ql3-local-execution \ && ln -s /workspace/packages/ql3-local-execution \
/opt/qinglong/node_modules/@qinglong/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 \ && ln -s /workspace/packages/ql3-local-process \
/opt/qinglong/node_modules/@qinglong/local-process \ /opt/qinglong/node_modules/@qinglong/local-process \
&& ln -s /workspace/packages/ql3-local-secret \ && 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 \ -p packages/ql3-local-process/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \ && /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-sqlite/tsconfig.json \ -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 \ && /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-ai/tsconfig.json \ -p packages/ql3-ai/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \ && /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 \ && /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-execution/tsconfig.json \ -p packages/ql3-local-execution/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \ && /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 FROM runtime-dependency-manifest AS external-dependencies
@@ -130,12 +140,85 @@ RUN rm -rf node_modules/.bin \
--exclude=@qinglong/ai \ --exclude=@qinglong/ai \
&& rm /tmp/ql3-prune-runtime-artifact.cjs && 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 \ RUN apk add --no-cache --upgrade \
libcrypto3=3.5.8-r0 \ libcrypto3=3.5.8-r0 \
libssl3=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 ARG SOURCE_REVISION=uncommitted
LABEL org.opencontainers.image.title="QingLong 3.0 Local Application" \ 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.sqlite-write-contract="52" \
io.qinglong.local.compose-selection="1" io.qinglong.local.compose-selection="1"
ENV NODE_ENV=production
WORKDIR /opt/qinglong
COPY --from=assembled --chown=65532:65532 /opt/qinglong ./ COPY --from=assembled --chown=65532:65532 /opt/qinglong ./
USER 65532:65532 USER 65532:65532
ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js"] 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
+3 -1
View File
@@ -11,7 +11,9 @@
最新增量证据(2026-08-28): 最新增量证据(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 退出 0package boundary 保持 18 packages 且无 single-source/shallow packageCluster 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 退出 0package boundary 保持 18 packages 且无 single-source/shallow packageCluster 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 退出 0package 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 八文件开发 bundle184,648,192-byte archive 完成 checksum、canonical audit、load 与 exact identityDocker Desktop 在 Owner directory ownership 处按既有 POSIX 不等价失败关闭,未冒充原生 Linux 成功,正式 native exact-script 证据由手动 artifact job 产生。该切片不安装宿主 Node.js,不把 Operator 变成 daemon,不增加镜像 layer、workspace package、端口或稳态 RSS,也不触碰 2.x/migration。当前产物是无外部 listener、AI-excluded 的 headless Alpha runtime,不冒充 2.x Web UI、公开 release、生产 HA 或 LTS;首份真实双架构 v3 下载物仍需维护者授权 `produce_alpha_artifacts=true` - D-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 退出 0package 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 八文件开发 bundle184,648,192-byte archive 完成 checksum、canonical audit、load 与 exact identityDocker 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`
@@ -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 - `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 进程或端口; - 基础 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 视觉和交互已在 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 仍未完成
## 验证 ## 验证
@@ -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 KitD-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 runtimeConsole 不进入 2.x、Cluster 或基础 Edge/Standalone 闭包。
### 2. Console 保持 loopback-only
Console quickstart 只允许 Linux Docker hostApplication 使用 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-<arch>.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 ApplicationConsole 对其为零成本;
- 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 退出 014 档 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 实物门在提交前执行。
+2 -1
View File
@@ -515,7 +515,8 @@
| [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) | | [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) |
| [ADR-0510](./ADR-0510-cross-profile-alpha-stage-delivery-index.md) | 跨 Profile Alpha 阶段交付索引 | Accepted(首份实际 stage index 待维护者授权) | | [ADR-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-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 | AcceptedConsole Trial Kit 交付待后续切片 | | [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | AcceptedTrial Kit 交付已由 ADR-0513 闭合 |
| [ADR-0513](./ADR-0513-selectable-local-console-trial-kit.md) | 可选择的 Local Console Trial Kit | Accepted(首份实际双架构产物待维护者授权) |
## 规则 ## 规则
+14 -14
View File
@@ -15,16 +15,16 @@
当维护者显式选择 `alpha_artifact_scope=all` 时,还会生成 `Alpha stage index`。它把同一次 run 的 Local/Cluster milestone 交叉绑定,并为 Edge、Standalone、Cluster 给出目标架构的最小 artifact 选择;这是阶段交付导航,不是正式 release catalog。只生成 Local 或 Cluster 时,各自 milestone 仍可独立成立,不制造一个不完整的总索引。 当维护者显式选择 `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-418 headless Trial Kit v4 | 默认低配变体;POSIX shell + Docker 一条命令完成 checksum、load、fresh setup、首 Owner、Application active/stop;无 listener | 首份远端双架构实际 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 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 工程候选: 提交 `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。 该本地 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 verificationADR-0511 的 `@v3` 增加 canonical quickstartADR-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 天: 成功后同一次 run 生成、保留 30 天:
- `ql3-alpha-<commit>-local-amd64``ql3-alpha-<commit>-local-arm64` - `ql3-alpha-<commit>-local-<variant>-amd64``ql3-alpha-<commit>-local-<variant>-arm64`
- `ql3-alpha-<commit>-local-milestone` - `ql3-alpha-<commit>-local-<variant>-milestone`
- `ql3-alpha-<commit>-control-<arch>``control-ai-<arch>``admin-<arch>``worker-<arch>` - `ql3-alpha-<commit>-control-<arch>``control-ai-<arch>``admin-<arch>``worker-<arch>`
- `ql3-alpha-<commit>-cluster-milestone` - `ql3-alpha-<commit>-cluster-milestone`
- 仅 `alpha_artifact_scope=all``ql3-alpha-<commit>-stage-index` - 仅 `alpha_artifact_scope=all``ql3-alpha-<commit>-stage-index`
Local artifact 含: Local artifact 含:
- 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-<arch>.docker.tar`共享 Node 基础层在 archive 中去重; - 一个包含所选 Application 与短生命周期 operator 的 archiveheadless 为 `qinglong3-local-trial-kit-<arch>.docker.tar`Console 为 `qinglong3-local-console-trial-kit-<arch>.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 - 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 - 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,它不替代正式签名; - `verification-evidence.json` 绑定 `workflow_dispatch` 的 workflow ref/SHA、run ID/attempt、同架构两个 exact image ID 和完整 gate 集;下载者仍须到 GitHub 交叉检查 run,它不替代正式签名;
- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM - 与实际只读镜像 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。 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。 任何 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 也可以选择 `standalone` 和自定义容器名。quickstart 会先执行 `SHA256SUMS`,再核对 exact
镜像身份并完成 fresh setup、首 Owner 与 Application active;成功后输出 logs、stop 和 镜像身份并完成 fresh setup、首 Owner 与 Application active;成功后输出 logs、stop 和
remove 命令。当前是无外部 listener、AI-excluded 的 headless runtime 阶段成果,不是 remove 命令。Headless 不开放 listenerConsole 只在 Linux host 的 `127.0.0.1:5700`
2.x Web UI 的替代品。 提供 Alpha 操作面,远程访问必须经 SSH tunnel。两者都不是 2.x Web UI 的生产替代品。
## 手工验证与最小 smoke ## 手工验证与最小 smoke
+3 -2
View File
@@ -6,7 +6,7 @@
| 设备或环境 | 选择 | 稳态组件 | 需要下载 | | 设备或环境 | 选择 | 稳态组件 | 需要下载 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 低配路由器、NAS、单机 | `deploymentSelections.local` | 仅 Local ApplicationOperator 只在 setup/upgrade/recovery 短暂运行 | 目标架构的一个 Local Trial Kit | | 低配路由器、NAS、单机 | `deploymentSelections.local`;默认选择 `headless`,需要浏览器操作面时显式选择 `console` | 仅 Local ApplicationOperator 只在 setup/upgrade/recovery 短暂运行 | 目标架构、目标 variant 的一个 Local Trial Kit |
| 临时 K3s/Kubernetes 集群 | `deploymentSelections.cluster` | control、admin、workerAI 按需增加 control-ai | 目标架构的三个 required artifact;需要 AI 时再下载 optional artifact | | 临时 K3s/Kubernetes 集群 | `deploymentSelections.cluster` | control、admin、workerAI 按需增加 control-ai | 目标架构的三个 required artifact;需要 AI 时再下载 optional artifact |
不得把四个 Cluster 角色部署到低配路由器,也不需要为一台 amd64 主机下载 arm64 归档。索引固定列出十个可选择 artifact,但每个部署者只下载目标 Profile、架构和角色所需的子集。 不得把四个 Cluster 角色部署到低配路由器,也不需要为一台 amd64 主机下载 arm64 归档。索引固定列出十个可选择 artifact,但每个部署者只下载目标 Profile、架构和角色所需的子集。
@@ -15,7 +15,8 @@
1. 在本目录运行 `sha256sum --check SHA256SUMS` 1. 在本目录运行 `sha256sum --check SHA256SUMS`
2. 检查 `manifest.json` 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` - maturity 为 `alpha_stage_delivery_not_public_release`
- source revision、run ID/attempt 是准备验证的显式 workflow run - source revision、run ID/attempt 是准备验证的显式 workflow run
- `milestones.local``milestones.cluster` 分别指向同一提交的 milestone artifact。 - `milestones.local``milestones.cluster` 分别指向同一提交的 milestone artifact。
+3 -2
View File
@@ -9,7 +9,7 @@
- 不提供公开 GHCR tag、Cosign 签名、GitHub attestation、catalog、生产升级或长期支持承诺; - 不提供公开 GHCR tag、Cosign 签名、GitHub attestation、catalog、生产升级或长期支持承诺;
- Cluster/Kubernetes 仍按独立 Integration Candidate 与 Public Release Set 门验收。 - Cluster/Kubernetes 仍按独立 Integration Candidate 与 Public Release Set 门验收。
单个架构 artifact 提前上传并不代表 milestone 成功。没有 `ql3-alpha-<sourceRevision>-local-milestone` 索引、索引 workflow 不是成功终态、run/attempt 不一致或索引审计失败时,已有的大归档只能作为失败运行的中间文件,不得交付用户。 单个架构 artifact 提前上传并不代表 milestone 成功。没有 `ql3-alpha-<sourceRevision>-local-<variant>-milestone` 索引、索引 workflow 不是成功终态、run/attempt/variant 不一致或索引审计失败时,已有的大归档只能作为失败运行的中间文件,不得交付用户。
## 选择并验证下载物 ## 选择并验证下载物
@@ -20,7 +20,8 @@
``` ```
2. 打开 `manifest.json`,确认: 2. 打开 `manifest.json`,确认:
- `schema``qinglong/alpha-local-milestone@v1` - `schema``qinglong/alpha-local-milestone@v2`
- `variant``headless``console`,且两个架构记录都使用同一变体;
- `sourceRevision` 是准备试用的完整 40 位提交; - `sourceRevision` 是准备试用的完整 40 位提交;
- `workflow.event``workflow_dispatch``workflow.job``local-alpha-milestone` - `workflow.event``workflow_dispatch``workflow.job``local-alpha-milestone`
- GitHub Actions 中对应 `runId/runAttempt` 的整条 `QingLong 3.0 CI` 为成功终态; - GitHub Actions 中对应 `runId/runAttempt` 的整条 `QingLong 3.0 CI` 为成功终态;
+14 -9
View File
@@ -2,7 +2,7 @@
本目录是绑定一个 QingLong 3.0 源码提交、一个 Linux 架构和一次显式 GitHub milestone run 的阶段试运行套件,不是公开 release 或生产升级承诺。它同时包含常驻 Application 镜像和短生命周期 operator 镜像;两者共享的 OCI layer 只在同一个 Docker archive 中保存一次。 本目录是绑定一个 QingLong 3.0 源码提交、一个 Linux 架构和一次显式 GitHub milestone run 的阶段试运行套件,不是公开 release 或生产升级承诺。它同时包含常驻 Application 镜像和短生命周期 operator 镜像;两者共享的 OCI layer 只在同一个 Docker archive 中保存一次。
一套 Trial Kit 只有被同一 run 的 `ql3-alpha-<sourceRevision>-local-milestone` 跨架构索引收录后才是可交付阶段产物。单个矩阵 job 提前上传、另一架构或完整 CI 随后失败时留下的 artifact 只是中间文件。先按 milestone `manifest.json` 选择本机架构并核对本 bundle manifest digest,再执行下述离线验收。 一套 Trial Kit 只有被同一 run 的 `ql3-alpha-<sourceRevision>-local-<variant>-milestone` 跨架构索引收录后才是可交付阶段产物。单个矩阵 job 提前上传、另一架构或完整 CI 随后失败时留下的 artifact 只是中间文件。先按 milestone `manifest.json` 选择 `variant`本机架构并核对本 bundle manifest digest,再执行下述离线验收。
## 适用范围 ## 适用范围
@@ -11,6 +11,8 @@
- fresh、隔离的测试数据目录; - fresh、隔离的测试数据目录;
- 离线导入、设备兼容验证和 3.0 Alpha 用户旅程验证。 - 离线导入、设备兼容验证和 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。 不要把它直接用于生产数据、2.x 唯一数据目录或生产 Secret。Cluster/Kubernetes 节点应使用 Cluster Integration Candidate;本套件不包含 PostgreSQL HA、Worker 或 Cluster Admin。
## 离线验收 ## 离线验收
@@ -23,7 +25,8 @@ sha256sum --check SHA256SUMS
`manifest.json` 必须满足: `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; - `sourceRevision` 是你准备试用的完整 40 位 commit;
- `architecture` 与主机相同; - `architecture` 与主机相同;
- `maturity``alpha_candidate_not_public_release` - `maturity``alpha_candidate_not_public_release`
@@ -42,7 +45,7 @@ node scripts/ql3-local-alpha-trial-kit-bundle.cjs \
## 一条命令完成 Fresh 试运行 ## 一条命令完成 Fresh 试运行
v3 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX v4 bundle 内的 `quickstart.sh` 不依赖宿主 Node.js、jq 或 Compose,只需要 POSIX
shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全 shell、`sha256sum` 和已启动的 Docker。必须选择一个尚不存在、与 2.x/生产数据完全
隔离的绝对路径: 隔离的绝对路径:
@@ -66,11 +69,12 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`operator command
删除数据目录;确认不再需要诊断后由操作者显式删除该 fresh 测试目录。脚本拒绝既有目录, 删除数据目录;确认不再需要诊断后由操作者显式删除该 fresh 测试目录。脚本拒绝既有目录,
不能用于升级、迁移或接管 2.x。 不能用于升级、迁移或接管 2.x。
当前 Application 是无外部 listener、AI-excluded 的 headless Alpha runtime。该试运行可 Headless Application 是无外部 listener、AI-excluded 的最小 Alpha runtime。Console 变体同样
验证 3.0 SQLite、Owner authority、调度/插件基础与生命周期,但还不是 2.x Web UI 的 AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内仍严格绑定
可替代版本。D-417 已在 opt-in `application-api` Profile 提供首个 Local Web Console `127.0.0.1:5700` 的 Local API 可由宿主浏览器访问。成功后打开
但本 v3 Trial Kit 尚未携带该 Profile;不要期待 quickstart 启动后出现 Web 端口。Console `http://127.0.0.1:5700/`;远程主机必须建立 SSH tunnel。两种变体都只用于 fresh Alpha
当前使用方式与边界见 [Local Web Console](./ql3-local-web-console.md)。 不是 2.x Web UI 的生产替代版本。Console 能力和凭据边界见
[Local Web Console](./ql3-local-web-console.md)。
## 手工加载与最小 smoke ## 手工加载与最小 smoke
@@ -78,6 +82,7 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`operator command
```sh ```sh
docker load --input qinglong3-local-trial-kit-<arch>.docker.tar docker load --input qinglong3-local-trial-kit-<arch>.docker.tar
# Console 变体使用 qinglong3-local-console-trial-kit-<arch>.docker.tar
``` ```
以 manifest 中 `images.application.reference``images.operator.reference` 为准,分别核对 `docker image inspect` 返回的 image ID。然后执行无网络、只读 smoke: 以 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 试运行边界
完整 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 PIDStandalone 为 256 MiB、0.5 CPU、256 PIDoperator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PIDStandalone 为 256 MiB、0.5 CPU、256 PIDoperator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。
+30 -13
View File
@@ -12,7 +12,7 @@ const { sha256File } = require('./ql3-local-alpha-trial-kit-bundle.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-stage-index@v1'; const SCHEMA = 'qinglong/alpha-stage-index@v2';
const FILES = Object.freeze({ const FILES = Object.freeze({
readme: 'README.md', readme: 'README.md',
manifest: 'manifest.json', manifest: 'manifest.json',
@@ -118,8 +118,10 @@ function stageArtifactName(sourceRevision) {
return `ql3-alpha-${sourceRevision}-stage-index`; return `ql3-alpha-${sourceRevision}-stage-index`;
} }
function milestoneArtifactName(sourceRevision, product) { function milestoneArtifactName(sourceRevision, product, variant) {
return `ql3-alpha-${sourceRevision}-${product}-milestone`; return product === 'local'
? `ql3-alpha-${sourceRevision}-local-${variant}-milestone`
: `ql3-alpha-${sourceRevision}-cluster-milestone`;
} }
function validateWorkflow(document, sourceRevision) { function validateWorkflow(document, sourceRevision) {
@@ -189,8 +191,15 @@ function readMilestones(localMilestoneRoot, clusterMilestoneRoot) {
function expectedSelections(local, cluster) { function expectedSelections(local, cluster) {
return { return {
local: { local: {
profiles: ['edge', 'standalone'], variant: local.variant,
intent: 'fresh_non_production_trial', 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: Object.fromEntries(
ARCHITECTURES.map((architecture) => [ ARCHITECTURES.map((architecture) => [
architecture, architecture,
@@ -224,18 +233,19 @@ function expectedSelections(local, cluster) {
}; };
} }
function validateMilestoneRecord(record, product, sourceRevision) { function validateMilestoneRecord(record, product, sourceRevision, variant) {
const expectedMaturity = const expectedMaturity =
product === 'local' product === 'local'
? 'alpha_candidate_not_public_release' ? 'alpha_candidate_not_public_release'
: 'cluster_integration_candidate_not_public_release'; : 'cluster_integration_candidate_not_public_release';
const expectedSchema = const expectedSchema =
product === 'local' product === 'local'
? 'qinglong/alpha-local-milestone@v1' ? 'qinglong/alpha-local-milestone@v2'
: 'qinglong/alpha-cluster-milestone@v1'; : 'qinglong/alpha-cluster-milestone@v1';
if ( if (
!exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) || !exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) ||
record.artifactName !== milestoneArtifactName(sourceRevision, product) || record.artifactName !==
milestoneArtifactName(sourceRevision, product, variant) ||
record.schema !== expectedSchema || record.schema !== expectedSchema ||
record.maturity !== expectedMaturity || record.maturity !== expectedMaturity ||
!exactKeys(record.manifest, ['file', 'sha256', 'bytes']) || !exactKeys(record.manifest, ['file', 'sha256', 'bytes']) ||
@@ -293,7 +303,7 @@ function auditAlphaStageIndex(options) {
'deploymentSelections', 'deploymentSelections',
'readme', 'readme',
]) || ]) ||
manifest.schemaVersion !== 1 || manifest.schemaVersion !== 2 ||
manifest.schema !== SCHEMA || manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_stage_delivery_not_public_release' || manifest.maturity !== 'alpha_stage_delivery_not_public_release' ||
manifest.product !== 'qinglong3' || manifest.product !== 'qinglong3' ||
@@ -321,11 +331,13 @@ function auditAlphaStageIndex(options) {
manifest.milestones.local, manifest.milestones.local,
'local', 'local',
manifest.sourceRevision, manifest.sourceRevision,
milestones.local.variant,
); );
validateMilestoneRecord( validateMilestoneRecord(
manifest.milestones.cluster, manifest.milestones.cluster,
'cluster', 'cluster',
manifest.sourceRevision, manifest.sourceRevision,
undefined,
); );
const milestoneManifests = { const milestoneManifests = {
local: fileRecord( local: fileRecord(
@@ -365,12 +377,15 @@ function auditAlphaStageIndex(options) {
} }
return Object.freeze({ return Object.freeze({
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/alpha-stage-index-audit@v1', schema: 'qinglong/alpha-stage-index-audit@v2',
version: manifest.version, version: manifest.version,
sourceRevision: manifest.sourceRevision, sourceRevision: manifest.sourceRevision,
workflowRunId: manifest.workflow.runId, workflowRunId: manifest.workflow.runId,
workflowRunAttempt: manifest.workflow.runAttempt, workflowRunAttempt: manifest.workflow.runAttempt,
profiles: ['edge', 'standalone', 'cluster'], profiles: [
...manifest.deploymentSelections.local.profiles,
'cluster',
],
artifactCount: 10, artifactCount: 10,
compatible: true, compatible: true,
}); });
@@ -441,7 +456,7 @@ function finalizeAlphaStageIndex(options) {
path.join(normalized.outputRoot, FILES.readme), path.join(normalized.outputRoot, FILES.readme),
); );
const manifest = { const manifest = {
schemaVersion: 1, schemaVersion: 2,
schema: SCHEMA, schema: SCHEMA,
maturity: 'alpha_stage_delivery_not_public_release', maturity: 'alpha_stage_delivery_not_public_release',
product: 'qinglong3', product: 'qinglong3',
@@ -461,6 +476,7 @@ function finalizeAlphaStageIndex(options) {
artifactName: milestoneArtifactName( artifactName: milestoneArtifactName(
normalized.sourceRevision, normalized.sourceRevision,
'local', 'local',
normalized.local.variant,
), ),
schema: normalized.local.schema, schema: normalized.local.schema,
maturity: normalized.local.maturity, maturity: normalized.local.maturity,
@@ -473,6 +489,7 @@ function finalizeAlphaStageIndex(options) {
artifactName: milestoneArtifactName( artifactName: milestoneArtifactName(
normalized.sourceRevision, normalized.sourceRevision,
'cluster', 'cluster',
undefined,
), ),
schema: normalized.cluster.schema, schema: normalized.cluster.schema,
maturity: normalized.cluster.maturity, maturity: normalized.cluster.maturity,
@@ -542,7 +559,7 @@ function auditAlphaStageIndexWorkflow(root = DEFAULT_ROOT) {
`if: ${condition}`, `if: ${condition}`,
' - local-alpha-milestone\n', ' - local-alpha-milestone\n',
' - cluster-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`, `name: ql3-alpha-${'${{ github.sha }}'}-cluster-milestone`,
'scripts/ql3-alpha-stage-index.cjs', 'scripts/ql3-alpha-stage-index.cjs',
'--mode=finalize', '--mode=finalize',
+25 -1
View File
@@ -76,6 +76,30 @@ const IMAGE_PROFILES = Object.freeze({
'drizzle-orm': '1.0.0-rc.4', '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({ 'local-operator': Object.freeze({
id: 'local-operator', id: 'local-operator',
buildManifestPath: 'deploy/containers/ql3-local-operator/package.json', buildManifestPath: 'deploy/containers/ql3-local-operator/package.json',
@@ -132,7 +156,7 @@ function resolveImageProfile(value = 'control') {
const profile = IMAGE_PROFILES[value]; const profile = IMAGE_PROFILES[value];
if (!profile) { if (!profile) {
throw new Error( 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; return profile;
@@ -12,6 +12,7 @@ const IMAGES = Object.freeze([
'control', 'control',
'control-ai', 'control-ai',
'local', 'local',
'local-console',
'local-operator', 'local-operator',
'worker', 'worker',
]); ]);
@@ -109,6 +110,7 @@ function auditImageOsVulnerabilityPolicy(
control: 0, control: 0,
'control-ai': 0, 'control-ai': 0,
local: 0, local: 0,
'local-console': 0,
'local-operator': 0, 'local-operator': 0,
worker: 0, worker: 0,
}), }),
@@ -120,6 +122,7 @@ function auditImageOsVulnerabilityPolicy(
control: 0, control: 0,
'control-ai': 0, 'control-ai': 0,
local: 0, local: 0,
'local-console': 0,
'local-operator': 0, 'local-operator': 0,
worker: 0, worker: 0,
}; };
+31 -12
View File
@@ -7,11 +7,12 @@ const path = require('node:path');
const { const {
auditLocalAlphaTrialKit, auditLocalAlphaTrialKit,
sha256File, sha256File,
VARIANTS,
} = require('./ql3-local-alpha-trial-kit-bundle.cjs'); } = require('./ql3-local-alpha-trial-kit-bundle.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); 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 ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const FILES = Object.freeze({ const FILES = Object.freeze({
readme: 'README.md', readme: 'README.md',
@@ -126,8 +127,8 @@ function checksumContents(root, names) {
.join('\n')}\n`; .join('\n')}\n`;
} }
function artifactName(sourceRevision, architecture) { function artifactName(sourceRevision, architecture, variant = 'headless') {
return `ql3-alpha-${sourceRevision}-local-${architecture}`; return `ql3-alpha-${sourceRevision}-local-${variant}-${architecture}`;
} }
function validateIdentity(options) { function validateIdentity(options) {
@@ -150,6 +151,7 @@ function validateFinalizeOptions(options) {
const outputRoot = path.resolve(options.outputRoot || ''); const outputRoot = path.resolve(options.outputRoot || '');
const parent = path.dirname(outputRoot); const parent = path.dirname(outputRoot);
if ( if (
!VARIANTS.includes(options.variant) ||
!path.isAbsolute(outputRoot) || !path.isAbsolute(outputRoot) ||
fs.existsSync(outputRoot) || fs.existsSync(outputRoot) ||
fs.realpathSync(parent) !== parent fs.realpathSync(parent) !== parent
@@ -179,6 +181,7 @@ function validateFinalizeOptions(options) {
'milestone README', 'milestone README',
), ),
sourceRevision: options.sourceRevision, sourceRevision: options.sourceRevision,
variant: options.variant,
repository: options.repository, repository: options.repository,
workflowRef: options.workflowRef, workflowRef: options.workflowRef,
workflowSha: options.workflowSha, workflowSha: options.workflowSha,
@@ -196,12 +199,17 @@ function bundleRecord(options, architecture) {
report.architecture !== architecture || report.architecture !== architecture ||
report.sourceRevision !== options.sourceRevision || report.sourceRevision !== options.sourceRevision ||
report.workflowRunId !== options.runId || 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`); fail(`${architecture} trial kit is detached from the milestone run`);
} }
return Object.freeze({ return Object.freeze({
artifactName: artifactName(options.sourceRevision, architecture), artifactName: artifactName(
options.sourceRevision,
architecture,
options.variant,
),
architecture, architecture,
bundleManifest: fileRecord( bundleManifest: fileRecord(
path.join(bundleRoot, 'manifest.json'), path.join(bundleRoot, 'manifest.json'),
@@ -226,7 +234,7 @@ function validateArtifactRecord(record, architecture, manifest) {
'verificationSha256', 'verificationSha256',
]) || ]) ||
record.artifactName !== record.artifactName !==
artifactName(manifest.sourceRevision, architecture) || artifactName(manifest.sourceRevision, architecture, manifest.variant) ||
record.architecture !== architecture || record.architecture !== architecture ||
!exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) || !exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) ||
record.bundleManifest.file !== 'manifest.json' || record.bundleManifest.file !== 'manifest.json' ||
@@ -298,16 +306,18 @@ function auditLocalAlphaMilestone(options) {
'schema', 'schema',
'maturity', 'maturity',
'product', 'product',
'variant',
'version', 'version',
'sourceRevision', 'sourceRevision',
'workflow', 'workflow',
'artifacts', 'artifacts',
'readme', 'readme',
]) || ]) ||
manifest.schemaVersion !== 1 || manifest.schemaVersion !== 2 ||
manifest.schema !== SCHEMA || manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' || manifest.product !== 'local' ||
!VARIANTS.includes(manifest.variant) ||
typeof manifest.version !== 'string' || typeof manifest.version !== 'string' ||
manifest.version.length < 3 || manifest.version.length < 3 ||
manifest.version.length > 64 || manifest.version.length > 64 ||
@@ -373,9 +383,10 @@ function auditLocalAlphaMilestone(options) {
} }
return Object.freeze({ return Object.freeze({
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/alpha-local-milestone-audit@v1', schema: 'qinglong/alpha-local-milestone-audit@v2',
sourceRevision: manifest.sourceRevision, sourceRevision: manifest.sourceRevision,
version: manifest.version, version: manifest.version,
variant: manifest.variant,
workflowRunId: manifest.workflow.runId, workflowRunId: manifest.workflow.runId,
workflowRunAttempt: manifest.workflow.runAttempt, workflowRunAttempt: manifest.workflow.runAttempt,
architectures: [...ARCHITECTURES], architectures: [...ARCHITECTURES],
@@ -422,10 +433,11 @@ function finalizeLocalAlphaMilestone(options) {
path.join(normalized.outputRoot, FILES.readme), path.join(normalized.outputRoot, FILES.readme),
); );
const manifest = { const manifest = {
schemaVersion: 1, schemaVersion: 2,
schema: SCHEMA, schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release', maturity: 'alpha_candidate_not_public_release',
product: 'local', product: 'local',
variant: normalized.variant,
version: [...versions][0], version: [...versions][0],
sourceRevision: normalized.sourceRevision, sourceRevision: normalized.sourceRevision,
workflow: { workflow: {
@@ -492,6 +504,10 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
'- local', '- local',
'- cluster', '- cluster',
'- all', '- all',
'local_alpha_variant:',
'default: headless',
'- headless',
'- console',
"github.run_id || 'validation'", "github.run_id || 'validation'",
"cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }}", "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', ' name: Finalize the Local Alpha milestone',
' needs:', ' needs:',
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c',
`name: ql3-alpha-${'${{ github.sha }}'}-local-amd64`, `name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-amd64`,
`name: ql3-alpha-${'${{ github.sha }}'}-local-arm64`, `name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-arm64`,
'scripts/ql3-local-alpha-milestone.cjs', 'scripts/ql3-local-alpha-milestone.cjs',
'--mode=finalize', '--mode=finalize',
'--variant=${{ inputs.local_alpha_variant }}',
'--mode=audit', '--mode=audit',
`name: ql3-alpha-${'${{ github.sha }}'}-local-milestone`, `name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-milestone`,
'retention-days: 30', 'retention-days: 30',
'overwrite: false', 'overwrite: false',
]; ];
@@ -587,6 +604,7 @@ function parseArguments(argv) {
'run-attempt', 'run-attempt',
'run-id', 'run-id',
'source-revision', 'source-revision',
'variant',
'workflow-ref', 'workflow-ref',
'workflow-sha', 'workflow-sha',
]; ];
@@ -605,6 +623,7 @@ function parseArguments(argv) {
}, },
readme: path.resolve(values.readme), readme: path.resolve(values.readme),
sourceRevision: values['source-revision'], sourceRevision: values['source-revision'],
variant: values.variant,
repository: values.repository, repository: values.repository,
workflowRef: values['workflow-ref'], workflowRef: values['workflow-ref'],
workflowSha: values['workflow-sha'], workflowSha: values['workflow-sha'],
+61 -18
View File
@@ -10,13 +10,14 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-trial-kit@v3'; const SCHEMA = 'qinglong/alpha-local-trial-kit@v4';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v1'; const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v2';
const QUICKSTART_TEMPLATE = path.join( const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT, DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh', 'scripts/templates/ql3-local-alpha-quickstart.sh',
); );
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const VARIANTS = Object.freeze(['headless', 'console']);
const ARCHIVE_MIN_BYTES = 1024; const ARCHIVE_MIN_BYTES = 1024;
const MAX_JSON_BYTES = 4 * 1024 * 1024; const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024; const MAX_README_BYTES = 512 * 1024;
@@ -43,6 +44,13 @@ const VERIFICATION = Object.freeze({
standaloneFreshLifecycle: 'passed', standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed', localApiCancellation: 'passed',
}); });
function verificationGates(variant) {
return Object.freeze({
...VERIFICATION,
consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable',
});
}
const WORKFLOW_IDENTITY = Object.freeze({ const WORKFLOW_IDENTITY = Object.freeze({
repository: 'whyour/qinglong', repository: 'whyour/qinglong',
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
@@ -162,11 +170,13 @@ function validateImageReference(value, label) {
} }
function normalizeImageInspection(inspection, options) { 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 labels = inspection?.Config?.Labels;
const expectedTitle = const expectedTitle =
role === 'application' 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'; : 'QingLong 3.0 Local Operator';
if ( if (
!SHA256_PATTERN.test(inspection?.Id || '') || !SHA256_PATTERN.test(inspection?.Id || '') ||
@@ -183,8 +193,14 @@ function normalizeImageInspection(inspection, options) {
} }
if ( if (
role === 'application' && role === 'application' &&
(labels?.['io.qinglong.profile'] !== 'edge,standalone' || (labels?.['io.qinglong.profile'] !==
labels?.['io.qinglong.ai'] !== 'excluded') (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'); fail('application image profile is incompatible');
} }
@@ -259,12 +275,14 @@ function validateVerificationEvidence(document, expected) {
'version', 'version',
'sourceRevision', 'sourceRevision',
'architecture', 'architecture',
'variant',
'applicationImageId', 'applicationImageId',
'operatorImageId', 'operatorImageId',
]) || ]) ||
document.subject.version !== expected.version || document.subject.version !== expected.version ||
document.subject.sourceRevision !== expected.sourceRevision || document.subject.sourceRevision !== expected.sourceRevision ||
document.subject.architecture !== expected.architecture || document.subject.architecture !== expected.architecture ||
document.subject.variant !== expected.variant ||
document.subject.applicationImageId !== expected.applicationImageId || document.subject.applicationImageId !== expected.applicationImageId ||
document.subject.operatorImageId !== expected.operatorImageId || document.subject.operatorImageId !== expected.operatorImageId ||
document.subject.applicationImageId === document.subject.operatorImageId || document.subject.applicationImageId === document.subject.operatorImageId ||
@@ -284,8 +302,9 @@ function validateVerificationEvidence(document, expected) {
document.workflow.job !== WORKFLOW_IDENTITY.job || document.workflow.job !== WORKFLOW_IDENTITY.job ||
!DECIMAL_ID_PATTERN.test(document.workflow.runId || '') || !DECIMAL_ID_PATTERN.test(document.workflow.runId || '') ||
!ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') || !ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') ||
!exactKeys(document.gates, Object.keys(VERIFICATION)) || !exactKeys(document.gates, Object.keys(verificationGates(expected.variant))) ||
JSON.stringify(document.gates) !== JSON.stringify(VERIFICATION) JSON.stringify(document.gates) !==
JSON.stringify(verificationGates(expected.variant))
) { ) {
fail('trial kit verification evidence is incompatible'); fail('trial kit verification evidence is incompatible');
} }
@@ -298,6 +317,7 @@ function validateVerificationOptions(options) {
const parent = path.dirname(output); const parent = path.dirname(output);
if ( if (
!ARCHITECTURES.includes(options.architecture) || !ARCHITECTURES.includes(options.architecture) ||
!VARIANTS.includes(options.variant) ||
!REVISION_PATTERN.test(options.sourceRevision || '') || !REVISION_PATTERN.test(options.sourceRevision || '') ||
!path.isAbsolute(output) || !path.isAbsolute(output) ||
fs.existsSync(output) || fs.existsSync(output) ||
@@ -316,6 +336,7 @@ function validateVerificationOptions(options) {
root, root,
output, output,
architecture: options.architecture, architecture: options.architecture,
variant: options.variant,
sourceRevision: options.sourceRevision, sourceRevision: options.sourceRevision,
applicationImage: validateImageReference( applicationImage: validateImageReference(
options.applicationImage, options.applicationImage,
@@ -343,6 +364,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
reference: normalized.applicationImage, reference: normalized.applicationImage,
revision: normalized.sourceRevision, revision: normalized.sourceRevision,
role: 'application', role: 'application',
variant: normalized.variant,
version: release.version, version: release.version,
}, },
); );
@@ -353,6 +375,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
reference: normalized.operatorImage, reference: normalized.operatorImage,
revision: normalized.sourceRevision, revision: normalized.sourceRevision,
role: 'operator', role: 'operator',
variant: normalized.variant,
version: release.version, version: release.version,
}, },
); );
@@ -364,6 +387,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
version: release.version, version: release.version,
sourceRevision: normalized.sourceRevision, sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture, architecture: normalized.architecture,
variant: normalized.variant,
applicationImageId: application.id, applicationImageId: application.id,
operatorImageId: operator.id, operatorImageId: operator.id,
}, },
@@ -376,15 +400,17 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
runId: normalized.runId, runId: normalized.runId,
runAttempt: normalized.runAttempt, runAttempt: normalized.runAttempt,
}, },
gates: { ...VERIFICATION }, gates: { ...verificationGates(normalized.variant) },
}; };
validateVerificationEvidence(evidence, evidence.subject); validateVerificationEvidence(evidence, evidence.subject);
writeExclusive(normalized.output, `${JSON.stringify(evidence, null, 2)}\n`); writeExclusive(normalized.output, `${JSON.stringify(evidence, null, 2)}\n`);
return evidence; return evidence;
} }
function archiveName(architecture) { function archiveName(architecture, variant = 'headless') {
return `qinglong3-local-trial-kit-${architecture}.docker.tar`; return variant === 'console'
? `qinglong3-local-console-trial-kit-${architecture}.docker.tar`
: `qinglong3-local-trial-kit-${architecture}.docker.tar`;
} }
function renderQuickstart(identity) { function renderQuickstart(identity) {
@@ -404,6 +430,7 @@ function renderQuickstart(identity) {
'@@ARCHITECTURE@@': identity.architecture, '@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision, '@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file, '@@ARCHIVE@@': identity.archive.file,
'@@VARIANT@@': identity.variant,
}); });
let rendered = template; let rendered = template;
for (const [token, value] of Object.entries(replacements)) { for (const [token, value] of Object.entries(replacements)) {
@@ -442,6 +469,7 @@ function validateCreateOptions(options) {
const parent = path.dirname(outputRoot); const parent = path.dirname(outputRoot);
if ( if (
!ARCHITECTURES.includes(options.architecture) || !ARCHITECTURES.includes(options.architecture) ||
!VARIANTS.includes(options.variant) ||
!REVISION_PATTERN.test(options.sourceRevision || '') || !REVISION_PATTERN.test(options.sourceRevision || '') ||
!path.isAbsolute(outputRoot) || !path.isAbsolute(outputRoot) ||
fs.existsSync(outputRoot) || fs.existsSync(outputRoot) ||
@@ -453,6 +481,7 @@ function validateCreateOptions(options) {
root, root,
outputRoot, outputRoot,
architecture: options.architecture, architecture: options.architecture,
variant: options.variant,
sourceRevision: options.sourceRevision, sourceRevision: options.sourceRevision,
applicationImage: validateImageReference( applicationImage: validateImageReference(
options.applicationImage, options.applicationImage,
@@ -501,7 +530,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
); );
validateSbom(applicationSbom, { validateSbom(applicationSbom, {
root: normalized.root, root: normalized.root,
profile: 'local', profile: normalized.variant === 'console' ? 'local-console' : 'local',
version: release.version, version: release.version,
}); });
validateSbom(operatorSbom, { validateSbom(operatorSbom, {
@@ -516,6 +545,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
reference: normalized.applicationImage, reference: normalized.applicationImage,
revision: normalized.sourceRevision, revision: normalized.sourceRevision,
role: 'application', role: 'application',
variant: normalized.variant,
version: release.version, version: release.version,
}, },
); );
@@ -526,6 +556,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
reference: normalized.operatorImage, reference: normalized.operatorImage,
revision: normalized.sourceRevision, revision: normalized.sourceRevision,
role: 'operator', role: 'operator',
variant: normalized.variant,
version: release.version, version: release.version,
}, },
); );
@@ -534,6 +565,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
version: release.version, version: release.version,
sourceRevision: normalized.sourceRevision, sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture, architecture: normalized.architecture,
variant: normalized.variant,
applicationImageId: application.id, applicationImageId: application.id,
operatorImageId: operator.id, operatorImageId: operator.id,
}); });
@@ -542,7 +574,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
try { try {
fs.mkdirSync(normalized.outputRoot, { mode: 0o700 }); fs.mkdirSync(normalized.outputRoot, { mode: 0o700 });
created = true; created = true;
const archive = archiveName(normalized.architecture); const archive = archiveName(normalized.architecture, normalized.variant);
const archivePath = path.join(normalized.outputRoot, archive); const archivePath = path.join(normalized.outputRoot, archive);
saveImages( saveImages(
[normalized.applicationImage, normalized.operatorImage], [normalized.applicationImage, normalized.operatorImage],
@@ -576,6 +608,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
const manifestIdentity = { const manifestIdentity = {
sourceRevision: normalized.sourceRevision, sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture, architecture: normalized.architecture,
variant: normalized.variant,
archive: { file: archive }, archive: { file: archive },
images: { application, operator }, images: { application, operator },
}; };
@@ -585,13 +618,14 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
0o700, 0o700,
); );
const manifest = { const manifest = {
schemaVersion: 4, schemaVersion: 5,
schema: SCHEMA, schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release', maturity: 'alpha_candidate_not_public_release',
product: 'local', product: 'local',
version: release.version, version: release.version,
sourceRevision: normalized.sourceRevision, sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture, architecture: normalized.architecture,
variant: normalized.variant,
archive: fileRecord(normalized.outputRoot, archive), archive: fileRecord(normalized.outputRoot, archive),
images: { application, operator }, images: { application, operator },
sboms: { sboms: {
@@ -679,6 +713,7 @@ function auditLocalAlphaTrialKit(options) {
'version', 'version',
'sourceRevision', 'sourceRevision',
'architecture', 'architecture',
'variant',
'archive', 'archive',
'images', 'images',
'sboms', 'sboms',
@@ -686,13 +721,14 @@ function auditLocalAlphaTrialKit(options) {
'readme', 'readme',
'verification', 'verification',
]) || ]) ||
manifest.schemaVersion !== 4 || manifest.schemaVersion !== 5 ||
manifest.schema !== SCHEMA || manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' || manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' || manifest.product !== 'local' ||
typeof manifest.version !== 'string' || typeof manifest.version !== 'string' ||
!REVISION_PATTERN.test(manifest.sourceRevision || '') || !REVISION_PATTERN.test(manifest.sourceRevision || '') ||
!ARCHITECTURES.includes(manifest.architecture) || !ARCHITECTURES.includes(manifest.architecture) ||
!VARIANTS.includes(manifest.variant) ||
!exactKeys(manifest.images, ['application', 'operator']) || !exactKeys(manifest.images, ['application', 'operator']) ||
!exactKeys(manifest.sboms, ['application', 'operator']) !exactKeys(manifest.sboms, ['application', 'operator'])
) { ) {
@@ -703,7 +739,7 @@ function auditLocalAlphaTrialKit(options) {
if (manifest.images.application.id === manifest.images.operator.id) { if (manifest.images.application.id === manifest.images.operator.id) {
fail('trial kit images must be distinct'); fail('trial kit images must be distinct');
} }
const expectedArchive = archiveName(manifest.architecture); const expectedArchive = archiveName(manifest.architecture, manifest.variant);
validateFileRecord(manifest.archive, expectedArchive, bundleRoot); validateFileRecord(manifest.archive, expectedArchive, bundleRoot);
if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) { if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) {
fail('Docker archive is unexpectedly small'); fail('Docker archive is unexpectedly small');
@@ -738,7 +774,7 @@ function auditLocalAlphaTrialKit(options) {
path.join(bundleRoot, FILES.applicationSbom), path.join(bundleRoot, FILES.applicationSbom),
'application SBOM', 'application SBOM',
), ),
'local', manifest.variant === 'console' ? 'local-console' : 'local',
manifest.version, manifest.version,
); );
validateOfflineSbom( validateOfflineSbom(
@@ -755,6 +791,7 @@ function auditLocalAlphaTrialKit(options) {
version: manifest.version, version: manifest.version,
sourceRevision: manifest.sourceRevision, sourceRevision: manifest.sourceRevision,
architecture: manifest.architecture, architecture: manifest.architecture,
variant: manifest.variant,
applicationImageId: manifest.images.application.id, applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id, operatorImageId: manifest.images.operator.id,
}, },
@@ -800,10 +837,11 @@ function auditLocalAlphaTrialKit(options) {
} }
return Object.freeze({ return Object.freeze({
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/alpha-local-trial-kit-audit@v1', schema: 'qinglong/alpha-local-trial-kit-audit@v2',
sourceRevision: manifest.sourceRevision, sourceRevision: manifest.sourceRevision,
version: manifest.version, version: manifest.version,
architecture: manifest.architecture, architecture: manifest.architecture,
variant: manifest.variant,
archiveSha256: manifest.archive.sha256, archiveSha256: manifest.archive.sha256,
applicationImageId: manifest.images.application.id, applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id, operatorImageId: manifest.images.operator.id,
@@ -845,6 +883,7 @@ function parseArguments(argv) {
'run-attempt', 'run-attempt',
'run-id', 'run-id',
'source-revision', 'source-revision',
'variant',
'workflow-ref', 'workflow-ref',
'workflow-sha', 'workflow-sha',
]; ];
@@ -857,6 +896,7 @@ function parseArguments(argv) {
mode: 'record-verification', mode: 'record-verification',
output: path.resolve(values.output), output: path.resolve(values.output),
architecture: values.architecture, architecture: values.architecture,
variant: values.variant,
sourceRevision: values['source-revision'], sourceRevision: values['source-revision'],
applicationImage: values['application-image'], applicationImage: values['application-image'],
operatorImage: values['operator-image'], operatorImage: values['operator-image'],
@@ -880,6 +920,7 @@ function parseArguments(argv) {
'output', 'output',
'readme', 'readme',
'source-revision', 'source-revision',
'variant',
'verification-evidence', 'verification-evidence',
]; ];
if ( if (
@@ -891,6 +932,7 @@ function parseArguments(argv) {
mode: 'create', mode: 'create',
outputRoot: path.resolve(values.output), outputRoot: path.resolve(values.output),
architecture: values.architecture, architecture: values.architecture,
variant: values.variant,
sourceRevision: values['source-revision'], sourceRevision: values['source-revision'],
applicationImage: values['application-image'], applicationImage: values['application-image'],
operatorImage: values['operator-image'], operatorImage: values['operator-image'],
@@ -933,6 +975,7 @@ module.exports = Object.freeze({
SCHEMA, SCHEMA,
VERIFICATION, VERIFICATION,
VERIFICATION_SCHEMA, VERIFICATION_SCHEMA,
VARIANTS,
archiveName, archiveName,
auditLocalAlphaTrialKit, auditLocalAlphaTrialKit,
createLocalAlphaTrialKit, createLocalAlphaTrialKit,
@@ -20,27 +20,30 @@ function fail(message) {
function argumentsFrom(argv) { function argumentsFrom(argv) {
const values = {}; const values = {};
for (const argument of argv) { for (const argument of argv) {
const match = /^--(application-image|operator-image|profile)=(.+)$/u.exec( const match =
argument, /^--(application-image|operator-image|profile|variant)=(.+)$/u.exec(
); argument,
);
if (!match || Object.hasOwn(values, match[1])) if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid'); fail('arguments are invalid');
values[match[1]] = match[2]; values[match[1]] = match[2];
} }
if ( if (
Object.keys(values).length !== 3 || Object.keys(values).length !== 4 ||
!IMAGE_PATTERN.test(values['application-image'] ?? '') || !IMAGE_PATTERN.test(values['application-image'] ?? '') ||
!IMAGE_PATTERN.test(values['operator-image'] ?? '') || !IMAGE_PATTERN.test(values['operator-image'] ?? '') ||
!['edge', 'standalone'].includes(values.profile) !['edge', 'standalone'].includes(values.profile) ||
!['headless', 'console'].includes(values.variant)
) { ) {
fail( fail(
'usage: --application-image=... --operator-image=... --profile=edge|standalone', 'usage: --application-image=... --operator-image=... --profile=edge|standalone --variant=headless|console',
); );
} }
return Object.freeze({ return Object.freeze({
applicationImage: values['application-image'], applicationImage: values['application-image'],
operatorImage: values['operator-image'], operatorImage: values['operator-image'],
profile: values.profile, profile: values.profile,
variant: values.variant,
}); });
} }
@@ -61,41 +64,54 @@ function docker(args, options = {}) {
return result.stdout.trim(); return result.stdout.trim();
} }
function inspectImages(applicationImage, operatorImage) { function inspectImages(applicationImage, operatorImage, variant) {
const application = docker([ const application = JSON.parse(
'image', docker(['image', 'inspect', applicationImage]),
'inspect', )[0];
'--format', const operator = JSON.parse(docker(['image', 'inspect', operatorImage]))[0];
'{{.Id}} {{.Architecture}} {{.Config.User}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}', const applicationLabels = application?.Config?.Labels;
applicationImage, const operatorLabels = operator?.Config?.Labels;
]).split(' '); const expectedTitle =
const operator = docker([ variant === 'console'
'image', ? 'QingLong 3.0 Local Console Application'
'inspect', : 'QingLong 3.0 Local Application';
'--format', const expectedProfile =
'{{.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"}}', variant === 'console'
operatorImage, ? 'edge-application-api,standalone-application-api'
]).split(' '); : 'edge,standalone';
if ( if (
!/^sha256:[0-9a-f]{64}$/u.test(application[0] ?? '') || !/^sha256:[0-9a-f]{64}$/u.test(application?.Id ?? '') ||
!/^sha256:[0-9a-f]{64}$/u.test(operator[0] ?? '') || !/^sha256:[0-9a-f]{64}$/u.test(operator?.Id ?? '') ||
application[1] !== operator[1] || application?.Architecture !== operator?.Architecture ||
!['amd64', 'arm64'].includes(application[1]) || !['amd64', 'arm64'].includes(application?.Architecture) ||
application[2] !== '65532:65532' || application?.Config?.User !== '65532:65532' ||
operator[2] !== '65532:65532' || operator?.Config?.User !== '65532:65532' ||
operator[3] !== 'short-lived' || applicationLabels?.['org.opencontainers.image.title'] !== expectedTitle ||
operator[4] !== 'local-owner-management' || applicationLabels?.['io.qinglong.profile'] !== expectedProfile ||
!/^[0-9a-f]{40}$/u.test(application[3] ?? '') || applicationLabels?.['io.qinglong.ai'] !== 'excluded' ||
application[3] !== operator[5] || (variant === 'console'
application[4] !== operator[6] || ? applicationLabels?.['io.qinglong.local.console'] !==
!/^3\.0\.0-alpha\.[0-9]+$/u.test(application[4] ?? '') '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'); fail('image identity, architecture or authority labels drifted');
} }
return Object.freeze({ return Object.freeze({
architecture: application[1], architecture: application.Architecture,
applicationId: application[0], applicationId: application.Id,
operatorId: operator[0], operatorId: operator.Id,
}); });
} }
@@ -326,6 +342,55 @@ function writeApplicationConfig(state) {
}, },
ai: { deployment: 'excluded' }, 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) { async function runApplication(state) {
@@ -344,7 +409,7 @@ async function runApplication(state) {
'--user', '--user',
`${state.uid}:${state.gid}`, `${state.uid}:${state.gid}`,
'--network', '--network',
'none', state.variant === 'console' ? 'host' : 'none',
'--cap-drop', '--cap-drop',
'ALL', 'ALL',
'--security-opt', '--security-opt',
@@ -363,13 +428,18 @@ async function runApplication(state) {
`${state.root}:/var/lib/qinglong3`, `${state.root}:/var/lib/qinglong3`,
state.applicationImage, state.applicationImage,
'--config', '--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'] }, { stdio: ['ignore', 'pipe', 'pipe'] },
); );
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
let active = false; let active = false;
let surfaceError;
let surface = Object.freeze({ status: 'not_applicable' });
let surfacePromise = Promise.resolve();
child.stdout.setEncoding('utf8'); child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8'); child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { child.stdout.on('data', (chunk) => {
@@ -385,7 +455,17 @@ async function runApplication(state) {
}) })
) { ) {
active = true; 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) => { child.stderr.on('data', (chunk) => {
@@ -403,6 +483,8 @@ async function runApplication(state) {
resolve({ code, signal }); resolve({ code, signal });
}); });
}); });
await surfacePromise;
if (surfaceError) throw surfaceError;
const events = stdout const events = stdout
.trim() .trim()
.split('\n') .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 { } finally {
spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' }); spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' });
} }
@@ -441,7 +523,11 @@ async function main() {
fail('a POSIX identity is required'); fail('a POSIX identity is required');
} }
const options = argumentsFrom(process.argv.slice(2)); 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( const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-trial-')), fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-trial-')),
); );
@@ -479,8 +565,9 @@ async function main() {
fail('durable SQLite result is invalid'); fail('durable SQLite result is invalid');
process.stdout.write( process.stdout.write(
`${JSON.stringify({ `${JSON.stringify({
schemaVersion: 1, schemaVersion: 2,
schema: 'qinglong/local-alpha-trial-kit-live@v1', schema: 'qinglong/local-alpha-trial-kit-live@v2',
variant: options.variant,
profile: options.profile, profile: options.profile,
architecture: images.architecture, architecture: images.architecture,
images: { images: {
@@ -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;
}
+112 -14
View File
@@ -29,6 +29,18 @@ const RUNTIME_DEPENDENCIES = Object.freeze({
}); });
const BUILD_PACKAGES = Object.freeze([ const BUILD_PACKAGES = Object.freeze([
'ql3-ai', '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-admin',
'ql3-local-application', 'ql3-local-application',
'ql3-local-command-file', 'ql3-local-command-file',
@@ -38,8 +50,8 @@ const BUILD_PACKAGES = Object.freeze([
'ql3-local-sqlite', 'ql3-local-sqlite',
'ql3-runtime-core', 'ql3-runtime-core',
]); ]);
const RUNTIME_PACKAGES = Object.freeze( const CONSOLE_RUNTIME_PACKAGES = Object.freeze(
BUILD_PACKAGES.filter((name) => name !== 'ql3-ai'), [...RUNTIME_PACKAGES, 'ql3-local-api', 'ql3-local-owner-console'].sort(),
); );
function readJson(filePath) { function readJson(filePath) {
@@ -174,6 +186,15 @@ function counts(values) {
return result; 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) { function auditDockerfile(contents, findings) {
const escapedBuildNodeImage = BUILD_NODE_IMAGE.replace( const escapedBuildNodeImage = BUILD_NODE_IMAGE.replace(
/[.*+?^${}()|[\]\\]/g, /[.*+?^${}()|[\]\\]/g,
@@ -188,7 +209,7 @@ function auditDockerfile(contents, findings) {
'gm', 'gm',
); );
const exactRuntimeBasePattern = new RegExp( const exactRuntimeBasePattern = new RegExp(
`^FROM ${escapedRuntimeNodeImage} AS runtime$`, `^FROM ${escapedRuntimeNodeImage} AS runtime-platform$`,
'gm', 'gm',
); );
if ( if (
@@ -221,8 +242,13 @@ function auditDockerfile(contents, findings) {
addFinding(findings, 'UNREVIEWED_RUNTIME_SURFACE'); 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( const buildCopies = captures(
contents, workspaceStage,
/^COPY packages\/(ql3-[a-z-]+) packages\/\1$/gm, /^COPY packages\/(ql3-[a-z-]+) packages\/\1$/gm,
).sort(); ).sort();
if (!sameJson(buildCopies, [...BUILD_PACKAGES].sort())) { if (!sameJson(buildCopies, [...BUILD_PACKAGES].sort())) {
@@ -230,7 +256,7 @@ function auditDockerfile(contents, findings) {
} }
const runtimeCopyCounts = counts( const runtimeCopyCounts = counts(
captures( captures(
contents, assembledStage,
/^COPY --from=workspace \/workspace\/packages\/(ql3-[a-z-]+)\/(?:package\.json|dist) /gm, /^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'); 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/')) { if (contents.includes('COPY --from=workspace /workspace/packages/ql3-ai/')) {
addFinding(findings, 'AI_PRESENT_IN_RUNTIME_STAGE'); addFinding(findings, 'AI_PRESENT_IN_RUNTIME_STAGE');
} }
@@ -264,20 +312,51 @@ function auditDockerfile(contents, findings) {
addFinding(findings, 'RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED'); addFinding(findings, 'RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED');
} }
if ( if (
!contents.includes('USER 65532:65532') || !consoleAssembledStage.includes(
!contents.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"]', 'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js"]',
) || ) ||
!contents.includes('io.qinglong.ai="excluded"') || !runtimeStage.includes('io.qinglong.ai="excluded"') ||
!contents.includes('io.qinglong.profile="edge,standalone"') || !runtimeStage.includes('io.qinglong.profile="edge,standalone"') ||
!contents.includes('io.qinglong.local.application-config="2,3,4"') || !runtimeStage.includes('io.qinglong.local.application-config="2,3,4"') ||
!contents.includes('io.qinglong.local.sqlite-contract-min="51"') || !runtimeStage.includes('io.qinglong.local.sqlite-contract-min="51"') ||
!contents.includes('io.qinglong.local.sqlite-contract-max="52"') || !runtimeStage.includes('io.qinglong.local.sqlite-contract-max="52"') ||
!contents.includes('io.qinglong.local.sqlite-write-contract="52"') || !runtimeStage.includes('io.qinglong.local.sqlite-write-contract="52"') ||
!contents.includes('io.qinglong.local.compose-selection="1"') !runtimeStage.includes('io.qinglong.local.compose-selection="1"')
) { ) {
addFinding(findings, 'RUNTIME_IDENTITY_OR_LABEL_DRIFT'); 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) { function auditWorkflow(contents, findings) {
@@ -295,6 +374,9 @@ function auditWorkflow(contents, findings) {
'pnpm audit:local-image:ql3', 'pnpm audit:local-image:ql3',
'docker build', 'docker build',
'--file deploy/containers/ql3-local-application/Dockerfile', '--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', '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}")"', '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', 'io.qinglong.local.application-config',
@@ -309,10 +391,18 @@ function auditWorkflow(contents, findings) {
'--memory=128m', '--memory=128m',
'--pids-limit=64', '--pids-limit=64',
'scripts/ql3-local-image-inventory.cjs', 'scripts/ql3-local-image-inventory.cjs',
'scripts/ql3-local-console-image-inventory.cjs',
'--inventory-root=/opt/qinglong/node_modules', '--inventory-root=/opt/qinglong/node_modules',
'node ../../scripts/ql3-build-package-closure.cjs', '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=edge',
'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone', '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) { for (const value of required) {
if (!job.includes(value)) { if (!job.includes(value)) {
@@ -356,6 +446,14 @@ function auditLocalImageContract(root) {
...Object.keys(RUNTIME_DEPENDENCIES), ...Object.keys(RUNTIME_DEPENDENCIES),
].sort(), ].sort(),
), ),
consoleRuntimePackages: Object.freeze(
[
...CONSOLE_RUNTIME_PACKAGES.map(
(name) => `@qinglong/${name.slice(4)}`,
),
...Object.keys(RUNTIME_DEPENDENCIES),
].sort(),
),
findings: Object.freeze(findings), findings: Object.freeze(findings),
compatible: findings.length === 0, compatible: findings.length === 0,
}); });
+4 -2
View File
@@ -199,10 +199,10 @@ function auditWorkflow(contents, findings) {
'--mode=record-verification', '--mode=record-verification',
'--mode=create', '--mode=create',
'--mode=audit', '--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}"', 'docker stop --time 30 "${QUICKSTART_CONTAINER}"',
'test -s "${QUICKSTART_ROOT}/qinglong3.sqlite"', '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"', '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"',
'--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"', '--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"',
'--readme=docs/operations/ql3-local-alpha-trial-kit.md', '--readme=docs/operations/ql3-local-alpha-trial-kit.md',
@@ -213,6 +213,8 @@ function auditWorkflow(contents, findings) {
'--job=${{ github.job }}', '--job=${{ github.job }}',
'--run-id=${{ github.run_id }}', '--run-id=${{ github.run_id }}',
'--run-attempt=${{ github.run_attempt }}', '--run-attempt=${{ github.run_attempt }}',
'--variant="${TRIAL_VARIANT}"',
'inputs.local_alpha_variant',
]; ];
for (const value of required) { for (const value of required) {
if (!contents.includes(value)) if (!contents.includes(value))
+7 -2
View File
@@ -760,17 +760,22 @@ if (require.main === module) {
try { try {
if (process.argv.length < 4) { if (process.argv.length < 4) {
fail( 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 arguments = process.argv.slice(3);
const report = pruneRuntimeArtifact(process.argv[2], { const report = pruneRuntimeArtifact(process.argv[2], {
entrySpecifiers: arguments.filter( entrySpecifiers: arguments.filter(
(argument) => !argument.startsWith('--exclude='), (argument) =>
!argument.startsWith('--exclude=') &&
!argument.startsWith('--retain-js='),
), ),
excludedInternalPackages: arguments excludedInternalPackages: arguments
.filter((argument) => argument.startsWith('--exclude=')) .filter((argument) => argument.startsWith('--exclude='))
.map((argument) => argument.slice('--exclude='.length)), .map((argument) => argument.slice('--exclude='.length)),
retainedJavaScriptFiles: arguments
.filter((argument) => argument.startsWith('--retain-js='))
.map((argument) => argument.slice('--retain-js='.length)),
}); });
process.stdout.write( process.stdout.write(
`${JSON.stringify({ schemaVersion: 1, ...report })}\n`, `${JSON.stringify({ schemaVersion: 1, ...report })}\n`,
+7 -1
View File
@@ -25,6 +25,12 @@ const CONTAINER_ROOTS = Object.freeze([
'deploy/containers/ql3-local-application', 'deploy/containers/ql3-local-application',
'deploy/containers/ql3-worker', '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([ const DEPLOYMENT_ROOTS = Object.freeze([
'deploy/kubernetes/ql3-cluster', 'deploy/kubernetes/ql3-cluster',
'deploy/kubernetes/ql3-worker', 'deploy/kubernetes/ql3-worker',
@@ -257,7 +263,7 @@ function auditReleaseVersionContract(rootInput = DEFAULT_ROOT) {
versionOccurrences( versionOccurrences(
dockerfile, dockerfile,
`org.opencontainers.image.version=\"${identity.version}\"`, `org.opencontainers.image.version=\"${identity.version}\"`,
) !== 1 ) !== CONTAINER_DOCKER_VERSION_LABEL_COUNTS[containerRoot]
) { ) {
fail(`container Dockerfile release identity drifted: ${containerRoot}`); fail(`container Dockerfile release identity drifted: ${containerRoot}`);
} }
@@ -9,6 +9,7 @@ OPERATOR_ID='@@OPERATOR_ID@@'
ARCHITECTURE='@@ARCHITECTURE@@' ARCHITECTURE='@@ARCHITECTURE@@'
SOURCE_REVISION='@@SOURCE_REVISION@@' SOURCE_REVISION='@@SOURCE_REVISION@@'
ARCHIVE='@@ARCHIVE@@' ARCHIVE='@@ARCHIVE@@'
VARIANT='@@VARIANT@@'
fail() { fail() {
printf '%s\n' "QingLong Local Alpha quickstart failed: $*" >&2 printf '%s\n' "QingLong Local Alpha quickstart failed: $*" >&2
@@ -37,6 +38,18 @@ case "$profile" in
;; ;;
*) usage ;; *) usage ;;
esac 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 case "$data_root" in
/|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/) /|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/)
@@ -92,6 +105,11 @@ EOF
cat >"$data_root/local-application.json" <<EOF cat >"$data_root/local-application.json" <<EOF
{"schema":"qinglong/local-application-process@v2","instanceId":"alpha-trial-local","profile":"$profile","storage":{"mode":"fresh","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","busyTimeoutMs":100},"runtime":{"receiptRoot":"/var/lib/qinglong3/receipts","artifactRoot":"/var/lib/qinglong3/artifacts","secretKeyringPath":"/var/lib/qinglong3/local-secret-keyring.json"},"pluginPackages":{"stagingRoot":"/var/lib/qinglong3/plugin-staging","activationRoot":"/var/lib/qinglong3/plugin-activation","recoverySource":{"mode":"disabled"},"pageSize":4,"maxPages":4,"taskPublicationPageSize":4,"taskPublicationMaxPages":4},"ai":{"deployment":"excluded"}} {"schema":"qinglong/local-application-process@v2","instanceId":"alpha-trial-local","profile":"$profile","storage":{"mode":"fresh","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","busyTimeoutMs":100},"runtime":{"receiptRoot":"/var/lib/qinglong3/receipts","artifactRoot":"/var/lib/qinglong3/artifacts","secretKeyringPath":"/var/lib/qinglong3/local-secret-keyring.json"},"pluginPackages":{"stagingRoot":"/var/lib/qinglong3/plugin-staging","activationRoot":"/var/lib/qinglong3/plugin-activation","recoverySource":{"mode":"disabled"},"pageSize":4,"maxPages":4,"taskPublicationPageSize":4,"taskPublicationMaxPages":4},"ai":{"deployment":"excluded"}}
EOF EOF
if [ "$VARIANT" = console ]; then
cat >"$data_root/local-api.json" <<EOF
{"schema":"qinglong/local-api-process@v1","deploymentRoot":"/var/lib/qinglong3","applicationConfigFilePath":"/var/lib/qinglong3/local-application.json","ownerPepperKeyringDirectory":"/var/lib/qinglong3/owner-peppers","listener":{"host":"127.0.0.1","port":5700}}
EOF
fi
chmod 0600 "$data_root"/*.json chmod 0600 "$data_root"/*.json
uid=$(id -u) uid=$(id -u)
@@ -129,12 +147,12 @@ trap cleanup EXIT
trap 'exit 130' HUP INT TERM trap 'exit 130' HUP INT TERM
container_id=$(docker run --detach --name "$container_name" \ container_id=$(docker run --detach --name "$container_name" \
--restart unless-stopped --read-only --user "$uid:$gid" --network none \ --restart unless-stopped --read-only --user "$uid:$gid" --network "$network_mode" \
--cap-drop ALL --security-opt no-new-privileges \ --cap-drop ALL --security-opt no-new-privileges \
--memory "$memory" --memory-swap "$memory" --cpus 0.5 --pids-limit "$pids" \ --memory "$memory" --memory-swap "$memory" --cpus 0.5 --pids-limit "$pids" \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m \
--mount "type=bind,src=$data_root,dst=/var/lib/qinglong3" \ --mount "type=bind,src=$data_root,dst=/var/lib/qinglong3" \
"$APPLICATION_IMAGE" --config /var/lib/qinglong3/local-application.json) "$APPLICATION_IMAGE" --config "/var/lib/qinglong3/$application_config")
printf '%s\n' "$container_id" >"$data_root/container.id" printf '%s\n' "$container_id" >"$data_root/container.id"
chmod 0600 "$data_root/container.id" chmod 0600 "$data_root/container.id"
@@ -153,10 +171,15 @@ done
umask "$old_umask" umask "$old_umask"
printf '%s\n' \ 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" \ "Data root: $data_root" \
"Owner deliveries: $data_root/owner-delivery" \ "Owner deliveries: $data_root/owner-delivery" \
"Logs: docker logs $container_name" \ "Logs: docker logs $container_name" \
"Stop: docker stop --time 30 $container_name" \ "Stop: docker stop --time 30 $container_name" \
"Remove container: docker rm $container_name" \ "Remove container: docker rm $container_name" \
'The fresh data root is retained until you remove it explicitly.' '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
+40 -8
View File
@@ -78,18 +78,19 @@ function writeMilestone(directory, manifest) {
fs.writeFileSync(path.join(directory, 'SHA256SUMS'), checksums(directory)); fs.writeFileSync(path.join(directory, 'SHA256SUMS'), checksums(directory));
} }
function localManifest(attempt = runAttempt) { function localManifest(attempt = runAttempt, variant = 'headless') {
return { return {
schemaVersion: 1, schemaVersion: 2,
schema: 'qinglong/alpha-local-milestone@v1', schema: 'qinglong/alpha-local-milestone@v2',
maturity: 'alpha_candidate_not_public_release', maturity: 'alpha_candidate_not_public_release',
product: 'local', product: 'local',
variant,
version, version,
sourceRevision: revision, sourceRevision: revision,
workflow: workflow('local-alpha-milestone', attempt), workflow: workflow('local-alpha-milestone', attempt),
artifacts: { artifacts: {
amd64: { amd64: {
artifactName: `ql3-alpha-${revision}-local-amd64`, artifactName: `ql3-alpha-${revision}-local-${variant}-amd64`,
architecture: 'amd64', architecture: 'amd64',
bundleManifest: { bundleManifest: {
file: 'manifest.json', file: 'manifest.json',
@@ -102,7 +103,7 @@ function localManifest(attempt = runAttempt) {
verificationSha256: digest('5'), verificationSha256: digest('5'),
}, },
arm64: { arm64: {
artifactName: `ql3-alpha-${revision}-local-arm64`, artifactName: `ql3-alpha-${revision}-local-${variant}-arm64`,
architecture: 'arm64', architecture: 'arm64',
bundleManifest: { bundleManifest: {
file: 'manifest.json', file: 'manifest.json',
@@ -163,7 +164,10 @@ function fixture(t, options = {}) {
t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
const localMilestoneRoot = path.join(fixtureRoot, 'local'); const localMilestoneRoot = path.join(fixtureRoot, 'local');
const clusterMilestoneRoot = path.join(fixtureRoot, 'cluster'); const clusterMilestoneRoot = path.join(fixtureRoot, 'cluster');
writeMilestone(localMilestoneRoot, localManifest(options.localAttempt)); writeMilestone(
localMilestoneRoot,
localManifest(options.localAttempt, options.variant || 'headless'),
);
writeMilestone(clusterMilestoneRoot, clusterManifest(options.clusterAttempt)); writeMilestone(clusterMilestoneRoot, clusterManifest(options.clusterAttempt));
const readme = path.join(fixtureRoot, 'README-source.md'); const readme = path.join(fixtureRoot, 'README-source.md');
fs.writeFileSync(readme, '# QingLong 3.0 Alpha stage index\n'); 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) => { test('closes Local and Cluster milestones into one deployment-facing stage index', (t) => {
const paths = fixture(t); const paths = fixture(t);
const manifest = finalizeAlphaStageIndex(finalizeOptions(paths)); 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(Object.keys(manifest.milestones), ['local', 'cluster']);
assert.deepEqual( assert.deepEqual(
manifest.deploymentSelections.local.architectures.amd64.requiredArtifacts, manifest.deploymentSelections.local.architectures.amd64.requiredArtifacts,
[`ql3-alpha-${revision}-local-amd64`], [`ql3-alpha-${revision}-local-headless-amd64`],
); );
assert.deepEqual( assert.deepEqual(
manifest.deploymentSelections.cluster.architectures.arm64.requiredArtifacts, 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']); 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) => { test('rejects Local and Cluster milestones from different workflow attempts', (t) => {
const paths = fixture(t, { clusterAttempt: '2' }); const paths = fixture(t, { clusterAttempt: '2' });
assert.throws( assert.throws(
+34
View File
@@ -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', () => { test('generates the headless Worker image runtime closure', () => {
const document = createClusterImageSbom({ root, image: 'worker' }); const document = createClusterImageSbom({ root, image: 'worker' });
const report = auditClusterImageSbom(document, { const report = auditClusterImageSbom(document, {
@@ -56,6 +56,7 @@ test('accepts the empty fail-closed production exception policy', () => {
control: 0, control: 0,
'control-ai': 0, 'control-ai': 0,
local: 0, local: 0,
'local-console': 0,
'local-operator': 0, 'local-operator': 0,
worker: 0, worker: 0,
}, },
+54 -10
View File
@@ -29,7 +29,7 @@ const revision = 'a'.repeat(40);
const runId = '33047425710'; const runId = '33047425710';
const runAttempt = '2'; const runAttempt = '2';
function imageInspection(role, architecture, idCharacter) { function imageInspection(role, architecture, idCharacter, variant = 'headless') {
return { return {
Id: `sha256:${idCharacter.repeat(64)}`, Id: `sha256:${idCharacter.repeat(64)}`,
Os: 'linux', Os: 'linux',
@@ -39,15 +39,23 @@ function imageInspection(role, architecture, idCharacter) {
Labels: { Labels: {
'org.opencontainers.image.title': 'org.opencontainers.image.title':
role === 'application' 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', : 'QingLong 3.0 Local Operator',
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
'org.opencontainers.image.revision': revision, 'org.opencontainers.image.revision': revision,
'org.opencontainers.image.version': version, 'org.opencontainers.image.version': version,
...(role === 'application' ...(role === 'application'
? { ? {
'io.qinglong.profile': 'edge,standalone', 'io.qinglong.profile':
variant === 'console'
? 'edge-application-api,standalone-application-api'
: 'edge,standalone',
'io.qinglong.ai': 'excluded', 'io.qinglong.ai': 'excluded',
...(variant === 'console'
? { 'io.qinglong.local.console': 'offline-loopback' }
: {}),
} }
: { : {
'io.qinglong.lifecycle': 'short-lived', 'io.qinglong.lifecycle': 'short-lived',
@@ -60,6 +68,7 @@ function imageInspection(role, architecture, idCharacter) {
} }
function createBundle(fixtureRoot, architecture, options = {}) { function createBundle(fixtureRoot, architecture, options = {}) {
const variant = options.variant || 'headless';
const bundleFixture = path.join(fixtureRoot, architecture); const bundleFixture = path.join(fixtureRoot, architecture);
fs.mkdirSync(bundleFixture); fs.mkdirSync(bundleFixture);
const applicationSbom = path.join(bundleFixture, 'application.json'); const applicationSbom = path.join(bundleFixture, 'application.json');
@@ -69,7 +78,12 @@ function createBundle(fixtureRoot, architecture, options = {}) {
const outputRoot = path.join(bundleFixture, 'bundle'); const outputRoot = path.join(bundleFixture, 'bundle');
fs.writeFileSync( fs.writeFileSync(
applicationSbom, applicationSbom,
`${JSON.stringify(createClusterImageSbom({ root, image: 'local' }))}\n`, `${JSON.stringify(
createClusterImageSbom({
root,
image: variant === 'console' ? 'local-console' : 'local',
}),
)}\n`,
); );
fs.writeFileSync( fs.writeFileSync(
operatorSbom, operatorSbom,
@@ -88,7 +102,7 @@ function createBundle(fixtureRoot, architecture, options = {}) {
const adapters = { const adapters = {
inspectImage(image) { inspectImage(image) {
const role = image.includes('operator') ? 'operator' : 'application'; const role = image.includes('operator') ? 'operator' : 'application';
return imageInspection(role, architecture, characters[role]); return imageInspection(role, architecture, characters[role], variant);
}, },
saveImages(images, archivePath) { saveImages(images, archivePath) {
assert.deepEqual(images, [applicationImage, operatorImage]); assert.deepEqual(images, [applicationImage, operatorImage]);
@@ -104,6 +118,7 @@ function createBundle(fixtureRoot, architecture, options = {}) {
root, root,
output: verificationEvidence, output: verificationEvidence,
architecture, architecture,
variant,
sourceRevision: revision, sourceRevision: revision,
applicationImage, applicationImage,
operatorImage, operatorImage,
@@ -123,6 +138,7 @@ function createBundle(fixtureRoot, architecture, options = {}) {
root, root,
outputRoot, outputRoot,
architecture, architecture,
variant,
sourceRevision: revision, sourceRevision: revision,
applicationImage, applicationImage,
operatorImage, operatorImage,
@@ -152,9 +168,16 @@ function fixture(t, options = {}) {
readme, readme,
outputRoot: path.join(fixtureRoot, 'milestone'), outputRoot: path.join(fixtureRoot, 'milestone'),
bundles: { bundles: {
amd64: createBundle(fixtureRoot, 'amd64', options.amd64), amd64: createBundle(fixtureRoot, 'amd64', {
arm64: createBundle(fixtureRoot, 'arm64', options.arm64), ...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, bundles: paths.bundles,
readme: paths.readme, readme: paths.readme,
sourceRevision: revision, sourceRevision: revision,
variant: paths.variant,
repository: 'whyour/qinglong', repository: 'whyour/qinglong',
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
workflowSha: revision, workflowSha: revision,
@@ -177,16 +201,17 @@ function finalizeOptions(paths) {
test('finalizes two exact native trial kits into one closed milestone index', (t) => { test('finalizes two exact native trial kits into one closed milestone index', (t) => {
const paths = fixture(t); const paths = fixture(t);
const manifest = finalizeLocalAlphaMilestone(finalizeOptions(paths)); 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.equal(manifest.sourceRevision, revision);
assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']); assert.deepEqual(Object.keys(manifest.artifacts), ['amd64', 'arm64']);
assert.equal( assert.equal(
manifest.artifacts.amd64.artifactName, manifest.artifacts.amd64.artifactName,
`ql3-alpha-${revision}-local-amd64`, `ql3-alpha-${revision}-local-headless-amd64`,
); );
assert.equal( assert.equal(
manifest.artifacts.arm64.artifactName, manifest.artifacts.arm64.artifactName,
`ql3-alpha-${revision}-local-arm64`, `ql3-alpha-${revision}-local-headless-arm64`,
); );
assert.notEqual( assert.notEqual(
manifest.artifacts.amd64.archiveSha256, 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.deepEqual(report.architectures, ['amd64', 'arm64']);
assert.equal(report.workflowRunId, runId); assert.equal(report.workflowRunId, runId);
assert.equal(report.workflowRunAttempt, runAttempt); 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) => { test('rejects a trial kit from another run attempt before publishing', (t) => {
+83 -13
View File
@@ -27,6 +27,7 @@ const revision = 'a'.repeat(40);
function imageInspection( function imageInspection(
role, role,
idCharacter = role === 'application' ? '1' : '2', idCharacter = role === 'application' ? '1' : '2',
variant = 'headless',
) { ) {
return { return {
Id: `sha256:${idCharacter.repeat(64)}`, Id: `sha256:${idCharacter.repeat(64)}`,
@@ -37,15 +38,23 @@ function imageInspection(
Labels: { Labels: {
'org.opencontainers.image.title': 'org.opencontainers.image.title':
role === 'application' 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', : 'QingLong 3.0 Local Operator',
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
'org.opencontainers.image.revision': revision, 'org.opencontainers.image.revision': revision,
'org.opencontainers.image.version': version, 'org.opencontainers.image.version': version,
...(role === 'application' ...(role === 'application'
? { ? {
'io.qinglong.profile': 'edge,standalone', 'io.qinglong.profile':
variant === 'console'
? 'edge-application-api,standalone-application-api'
: 'edge,standalone',
'io.qinglong.ai': 'excluded', 'io.qinglong.ai': 'excluded',
...(variant === 'console'
? { 'io.qinglong.local.console': 'offline-loopback' }
: {}),
} }
: { : {
'io.qinglong.lifecycle': 'short-lived', 'io.qinglong.lifecycle': 'short-lived',
@@ -57,7 +66,7 @@ function imageInspection(
}; };
} }
function fixture(t) { function fixture(t, variant = 'headless') {
const fixtureRoot = fs.realpathSync( const fixtureRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-alpha-bundle-')), 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'); const readme = path.join(fixtureRoot, 'README-source.md');
fs.writeFileSync( fs.writeFileSync(
applicationSbom, applicationSbom,
`${JSON.stringify(createClusterImageSbom({ root, image: 'local' }))}\n`, `${JSON.stringify(
createClusterImageSbom({
root,
image: variant === 'console' ? 'local-console' : 'local',
}),
)}\n`,
); );
fs.writeFileSync( fs.writeFileSync(
operatorSbom, operatorSbom,
@@ -89,8 +103,8 @@ function fixture(t) {
outputRoot: path.join(fixtureRoot, 'bundle'), outputRoot: path.join(fixtureRoot, 'bundle'),
}; };
createLocalAlphaTrialKitVerificationEvidence( createLocalAlphaTrialKitVerificationEvidence(
verificationOptions(paths), verificationOptions(paths, { variant }),
adapters(), adapters({}, variant),
); );
return paths; return paths;
} }
@@ -100,6 +114,7 @@ function verificationOptions(paths, overrides = {}) {
root, root,
output: paths.verificationEvidence, output: paths.verificationEvidence,
architecture: 'arm64', architecture: 'arm64',
variant: 'headless',
sourceRevision: revision, sourceRevision: revision,
applicationImage: 'qinglong3-local-application:test-arm64', applicationImage: 'qinglong3-local-application:test-arm64',
operatorImage: 'qinglong3-local-operator: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 { return {
root, root,
outputRoot: paths.outputRoot, outputRoot: paths.outputRoot,
architecture: 'arm64', architecture: 'arm64',
variant,
sourceRevision: revision, sourceRevision: revision,
applicationImage: 'qinglong3-local-application:test-arm64', applicationImage: 'qinglong3-local-application:test-arm64',
operatorImage: 'qinglong3-local-operator:test-arm64', operatorImage: 'qinglong3-local-operator:test-arm64',
@@ -129,12 +145,12 @@ function createOptions(paths) {
}; };
} }
function adapters(overrides = {}) { function adapters(overrides = {}, variant = 'headless') {
return { return {
inspectImage(image) { inspectImage(image) {
return image.includes('operator') return image.includes('operator')
? imageInspection('operator') ? imageInspection('operator', '2', variant)
: imageInspection('application'); : imageInspection('application', '1', variant);
}, },
saveImages(images, archivePath) { saveImages(images, archivePath) {
assert.deepEqual(images, [ assert.deepEqual(images, [
@@ -150,7 +166,8 @@ function adapters(overrides = {}) {
test('materializes and offline-audits one closed two-image trial kit', (t) => { test('materializes and offline-audits one closed two-image trial kit', (t) => {
const paths = fixture(t); const paths = fixture(t);
const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters()); 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.sourceRevision, revision);
assert.equal(manifest.architecture, 'arm64'); assert.equal(manifest.architecture, 'arm64');
assert.equal(manifest.images.application.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'); const quickstartContents = fs.readFileSync(quickstart, 'utf8');
assert.match( assert.match(
quickstartContents, 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/); assert.match(quickstartContents, /qinglong3-local-application:test-arm64/);
const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot });
assert.equal(report.compatible, true); assert.equal(report.compatible, true);
assert.equal(report.sourceRevision, revision); assert.equal(report.sourceRevision, revision);
assert.equal(report.workflowRunId, '32990652047'); assert.equal(report.workflowRunId, '32990652047');
assert.equal(report.variant, 'headless');
assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [
'README.md', 'README.md',
'SHA256SUMS', '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) => { test('fails closed and removes a partial output on incompatible image identity', (t) => {
const paths = fixture(t); const paths = fixture(t);
const options = createOptions(paths); const options = createOptions(paths);
@@ -352,7 +421,7 @@ exit 1
); );
assert.match( assert.match(
run.stdout, run.stdout,
/QingLong 3\.0 Local Alpha is active \(edge, arm64\)/, /QingLong 3\.0 Local Alpha is active \(headless, edge, arm64\)/,
); );
assert.equal( assert.equal(
fs.readFileSync(path.join(dataRoot, 'container.id'), 'utf8'), 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', '--application-image=qinglong3-local-application:test-arm64',
'--operator-image=qinglong3-local-operator:test-arm64', '--operator-image=qinglong3-local-operator:test-arm64',
'--architecture=arm64', '--architecture=arm64',
'--variant=headless',
`--source-revision=${revision}`, `--source-revision=${revision}`,
'--repository=whyour/qinglong', '--repository=whyour/qinglong',
'--workflow-ref=whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', '--workflow-ref=whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
@@ -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/);
});
+44
View File
@@ -63,6 +63,50 @@ test('accepts the exact AI-excluded local application image contract', () => {
'croner', 'croner',
'semver', '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', () => { test('rejects a mutable runtime base image', () => {
@@ -1,4 +1,5 @@
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs'); const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); 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', () => { test('rejects symbolic links before making partial changes', () => {
const current = fixture(); const current = fixture();
try { try {
+20 -1
View File
@@ -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', () => { test('plans the exact governed version surface without touching legacy 2.x', () => {
const plan = createVersionTransitionPlan({ const plan = createVersionTransitionPlan({
root, root,
@@ -116,7 +135,7 @@ test('plans the exact governed version surface without touching legacy 2.x', ()
targetVersion: TARGET_VERSION, targetVersion: TARGET_VERSION,
}); });
assert.equal(plan.fileCount, 66); assert.equal(plan.fileCount, 66);
assert.equal(plan.replacementCount, 86); assert.equal(plan.replacementCount, 87);
assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION); assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION);
assert.equal(plan.legacyRootExcluded, true); assert.equal(plan.legacyRootExcluded, true);
assert.equal( assert.equal(