feat(ql3): add downloadable local alpha operator

This commit is contained in:
whyour
2026-08-26 09:20:23 +08:00
parent 3731ae6051
commit 2253b99066
16 changed files with 1771 additions and 15 deletions
+129 -4
View File
@@ -365,7 +365,9 @@ jobs:
- name: Audit immutable local image inputs
run: |
node --test test/back/ql3LocalImageAudit.test.cjs
node --test test/back/ql3LocalOperatorImageAudit.test.cjs
pnpm audit:local-image:ql3
pnpm audit:local-operator-image:ql3
- name: Build the native AI-excluded image
env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -375,6 +377,15 @@ jobs:
--build-arg SOURCE_REVISION=${{ github.sha }}
--tag "${IMAGE}"
.
- name: Build the native short-lived Local operator image
env:
OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }}
run: >-
docker build
--file deploy/containers/ql3-local-operator/Dockerfile
--build-arg SOURCE_REVISION=${{ github.sha }}
--tag "${OPERATOR_IMAGE}"
.
- name: Materialize the reviewed local OS vulnerability exceptions
run: >-
node scripts/ql3-image-os-vulnerability-policy.cjs
@@ -395,6 +406,21 @@ jobs:
timeout: '10m0s'
cache: 'false'
trivyignores: ${{ runner.temp }}/ql3-local-${{ matrix.image_arch }}.trivyignore.yaml
- name: Reject high or critical Local operator OS vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
version: 'v0.70.0'
image-ref: qinglong3-local-operator: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-${{ matrix.image_arch }}.trivyignore.yaml
- name: Verify non-root identity and architecture
env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -406,6 +432,17 @@ jobs:
echo "unexpected image contract: ${actual}" >&2
exit 1
fi
- name: Verify the short-lived Local operator identity
env:
OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }}
EXPECTED: ${{ matrix.image_arch }} 65532:65532 short-lived local-owner-management none-by-default
run: |
set -euo pipefail
actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.lifecycle"}} {{index .Config.Labels "io.qinglong.authority"}} {{index .Config.Labels "io.qinglong.network"}}' "${OPERATOR_IMAGE}")"
if [[ "${actual}" != "${EXPECTED}" ]]; then
echo "unexpected Local operator image contract: ${actual}" >&2
exit 1
fi
- name: Reconcile the exact production package inventory
env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -420,6 +457,20 @@ jobs:
"${IMAGE}"
scripts/ql3-local-image-inventory.cjs
--inventory-root=/opt/qinglong/node_modules
- name: Reconcile the short-lived Local operator package inventory
env:
OPERATOR_IMAGE: qinglong3-local-operator: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
"${OPERATOR_IMAGE}"
scripts/ql3-local-operator-image-inventory.cjs
--inventory-root=/opt/qinglong/node_modules
- name: Generate and reconcile the reviewed CycloneDX SBOM
env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -438,6 +489,24 @@ jobs:
scripts/ql3-cluster-image-sbom.cjs \
--image=local \
--inventory-root=/opt/qinglong/node_modules
- name: Generate and reconcile the Local operator CycloneDX SBOM
env:
OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }}
run: |
node scripts/ql3-cluster-image-sbom.cjs \
--image=local-operator \
--output="${{ runner.temp }}/ql3-local-operator.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 \
"${OPERATOR_IMAGE}" \
scripts/ql3-cluster-image-sbom.cjs \
--image=local-operator \
--inventory-root=/opt/qinglong/node_modules
- name: Exercise the entrypoint under the router stress envelope
env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -453,6 +522,30 @@ jobs:
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m
"${IMAGE}"
--help
- name: Exercise the short-lived Local operator entrypoint
env:
OPERATOR_IMAGE: qinglong3-local-operator: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=32 \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \
"${OPERATOR_IMAGE}" --version
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=32 \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \
"${OPERATOR_IMAGE}" setup --help
- name: Build the short-lived fresh deployment authority
working-directory: packages/ql3-local-owner-cli
run: node ../../scripts/ql3-build-package-closure.cjs
@@ -462,6 +555,19 @@ jobs:
run: |
node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge
node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone
- name: Run the downloadable Local Alpha trial kit journey
env:
IMAGE: qinglong3-local-application: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
node scripts/ql3-local-alpha-trial-kit-live-contract.cjs \
--application-image="${IMAGE}" \
--operator-image="${OPERATOR_IMAGE}" \
--profile=standalone
- name: Run authenticated Local API cancellation through real Linux processes
env:
QL3_LOCAL_API_CANCELLATION_LIVE: '1'
@@ -487,10 +593,14 @@ jobs:
set -euo pipefail
umask 077
mkdir "${BUNDLE_ROOT}"
archive="${BUNDLE_ROOT}/qinglong3-local-application-${IMAGE_ARCH}.docker.tar"
docker image save --output "${archive}" "${IMAGE}"
archive="${BUNDLE_ROOT}/qinglong3-local-trial-kit-${IMAGE_ARCH}.docker.tar"
docker image save --output "${archive}" \
"${IMAGE}" \
"qinglong3-local-operator:ci-${IMAGE_ARCH}"
cp "${RUNNER_TEMP}/ql3-local-application.cdx.json" \
"${BUNDLE_ROOT}/qinglong3-local-application.cdx.json"
cp "${RUNNER_TEMP}/ql3-local-operator.cdx.json" \
"${BUNDLE_ROOT}/qinglong3-local-operator.cdx.json"
cp docs/operations/ql3-alpha-candidate.md "${BUNDLE_ROOT}/README.md"
archive_sha256="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)"
ARCHIVE="${archive}" ARCHIVE_SHA256="${archive_sha256}" \
@@ -502,9 +612,13 @@ jobs:
const imageId = childProcess
.execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', process.env.IMAGE], { encoding: 'utf8' })
.trim();
const operatorImage = `qinglong3-local-operator:ci-${process.env.IMAGE_ARCH}`;
const operatorImageId = childProcess
.execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', operatorImage], { encoding: 'utf8' })
.trim();
const manifest = {
schemaVersion: 1,
schema: 'qinglong/alpha-native-image@v1',
schemaVersion: 2,
schema: 'qinglong/alpha-local-trial-kit@v1',
maturity: 'alpha_candidate_not_public_release',
product: 'local',
version: release.version,
@@ -514,10 +628,21 @@ jobs:
imageId,
archive: process.env.ARCHIVE.split('/').pop(),
archiveSha256: process.env.ARCHIVE_SHA256,
operator: {
lifecycle: 'short-lived',
network: 'none-by-default',
image: operatorImage,
imageId: operatorImageId,
archive: process.env.ARCHIVE.split('/').pop(),
archiveSha256: process.env.ARCHIVE_SHA256,
},
verification: {
osVulnerabilityPolicy: 'passed',
sbomInventoryReconciliation: 'passed',
router128MiBEntrypoint: 'passed',
operator128MiBEntrypoint: 'passed',
operatorPackageInventory: 'passed',
freshOwnerJourney: 'passed',
edgeFreshLifecycle: 'passed',
standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed',
@@ -0,0 +1,146 @@
FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS dependency-manifest
WORKDIR /opt/qinglong
COPY deploy/containers/ql3-local-operator/package.json ./
COPY deploy/containers/ql3-local-operator/package-lock.json ./
FROM dependency-manifest AS build-dependencies
RUN npm ci --ignore-scripts --no-audit --no-fund \
&& npm cache clean --force
FROM dependency-manifest AS runtime-dependency-manifest
COPY deploy/containers/ql3-local-operator/runtime-dependencies/package.json \
package.json
COPY deploy/containers/ql3-local-operator/runtime-dependencies/package-lock.json \
package-lock.json
FROM build-dependencies AS workspace
WORKDIR /workspace
COPY packages/ql3-ai packages/ql3-ai
COPY packages/ql3-local-admin packages/ql3-local-admin
COPY packages/ql3-local-command-file packages/ql3-local-command-file
COPY packages/ql3-local-owner-cli packages/ql3-local-owner-cli
COPY packages/ql3-local-owner-console packages/ql3-local-owner-console
COPY packages/ql3-local-secret packages/ql3-local-secret
COPY packages/ql3-local-sqlite packages/ql3-local-sqlite
COPY packages/ql3-runtime-core packages/ql3-runtime-core
RUN ln -s /opt/qinglong/node_modules node_modules \
&& mkdir -p /opt/qinglong/node_modules/@qinglong \
&& ln -s /workspace/packages/ql3-ai \
/opt/qinglong/node_modules/@qinglong/ai \
&& ln -s /workspace/packages/ql3-local-admin \
/opt/qinglong/node_modules/@qinglong/local-admin \
&& ln -s /workspace/packages/ql3-local-command-file \
/opt/qinglong/node_modules/@qinglong/local-command-file \
&& ln -s /workspace/packages/ql3-local-owner-cli \
/opt/qinglong/node_modules/@qinglong/local-owner-cli \
&& ln -s /workspace/packages/ql3-local-owner-console \
/opt/qinglong/node_modules/@qinglong/local-owner-console \
&& ln -s /workspace/packages/ql3-local-secret \
/opt/qinglong/node_modules/@qinglong/local-secret \
&& ln -s /workspace/packages/ql3-local-sqlite \
/opt/qinglong/node_modules/@qinglong/local-sqlite \
&& ln -s /workspace/packages/ql3-runtime-core \
/opt/qinglong/node_modules/@qinglong/runtime-core \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-runtime-core/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-command-file/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-sqlite/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-ai/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-secret/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-admin/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-owner-console/tsconfig.json \
&& /opt/qinglong/node_modules/.bin/tsc \
-p packages/ql3-local-owner-cli/tsconfig.json
FROM runtime-dependency-manifest AS external-dependencies
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund \
&& npm cache clean --force
FROM external-dependencies AS assembled
RUN mkdir -p \
node_modules/@qinglong/ai \
node_modules/@qinglong/local-admin \
node_modules/@qinglong/local-command-file \
node_modules/@qinglong/local-owner-cli \
node_modules/@qinglong/local-owner-console \
node_modules/@qinglong/local-secret \
node_modules/@qinglong/local-sqlite \
node_modules/@qinglong/runtime-core
COPY --from=workspace /workspace/packages/ql3-ai/package.json \
node_modules/@qinglong/ai/package.json
COPY --from=workspace /workspace/packages/ql3-ai/dist \
node_modules/@qinglong/ai/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-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-owner-cli/package.json \
node_modules/@qinglong/local-owner-cli/package.json
COPY --from=workspace /workspace/packages/ql3-local-owner-cli/dist \
node_modules/@qinglong/local-owner-cli/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-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
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
RUN rm -rf node_modules/.bin \
&& find node_modules/@qinglong -type f \
\( -name '*.d.ts' -o -name '*.map' \) -delete \
&& find node_modules/@qinglong -type f -name '*.js' -exec \
sed -i '/^\/\/# sourceMappingURL=[A-Za-z0-9_.-]*\.map$/d' {} +
FROM node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 AS runtime
ARG SOURCE_REVISION=uncommitted
LABEL org.opencontainers.image.title="QingLong 3.0 Local Operator" \
org.opencontainers.image.description="QingLong 3.0 short-lived Local management authority" \
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.lifecycle="short-lived" \
io.qinglong.authority="local-owner-management" \
io.qinglong.network="none-by-default"
ENV NODE_ENV=production
WORKDIR /opt/qinglong
COPY --from=assembled --chown=65532:65532 /opt/qinglong ./
USER 65532:65532
ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-owner-cli/dist/product-cli/cli.js"]
+265
View File
@@ -0,0 +1,265 @@
{
"name": "@qinglong/local-operator-image",
"version": "3.0.0-alpha.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qinglong/local-operator-image",
"version": "3.0.0-alpha.2",
"license": "Apache-2.0",
"dependencies": {
"drizzle-orm": "1.0.0-rc.4",
"semver": "7.7.4"
},
"devDependencies": {
"@types/node": "24.13.3",
"typescript": "5.9.3"
},
"engines": {
"node": ">=24.18.0 <25"
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/drizzle-orm": {
"version": "1.0.0-rc.4",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-1.0.0-rc.4.tgz",
"integrity": "sha512-BT+pf+qoiYHqltoA88Jmf6ilGMXPlpfE0hEJKc2adRtMCAl25Swk/t5gXcWxZNAwdtf3F5gCd2FpeOyP/pT0Hw==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/client-rds-data": ">=3",
"@cloudflare/workers-types": ">=4",
"@effect/sql-d1": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-libsql": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-mysql2": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-pg": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-pglite": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-sqlite-bun": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-sqlite-do": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-sqlite-node": ">=4.0.0-beta.83 || >=4.0.0",
"@effect/sql-sqlite-wasm": ">=4.0.0-beta.83 || >=4.0.0",
"@electric-sql/pglite": ">=0.2.0",
"@libsql/client": ">=0.10.0",
"@libsql/client-wasm": ">=0.10.0",
"@neondatabase/serverless": ">=0.10.0",
"@op-engineering/op-sqlite": ">=2",
"@opentelemetry/api": "^1.4.1",
"@planetscale/database": ">=1.13",
"@sinclair/typebox": ">=0.34.8",
"@sqlitecloud/drivers": ">=1.0.653",
"@tidbcloud/serverless": "*",
"@tursodatabase/database": ">=0.6.0-pre.28 || >=0.6.0",
"@tursodatabase/database-common": ">=0.6.0-pre.28 || >=0.6.0",
"@tursodatabase/database-wasm": ">=0.6.0-pre.28 || >=0.6.0",
"@tursodatabase/serverless": ">=1.1.3",
"@tursodatabase/sync": ">=0.6.0-pre.28 || >=0.6.0",
"@types/better-sqlite3": "*",
"@types/mssql": "^9.1.4",
"@types/pg": "*",
"@types/sql.js": "*",
"@upstash/redis": ">=1.34.7",
"@vercel/postgres": ">=0.8.0",
"@xata.io/client": "*",
"arktype": ">=2.0.0",
"better-sqlite3": ">=9.3.0",
"bun-types": "*",
"effect": ">=4.0.0-beta.83 || >=4.0.0",
"expo-sqlite": ">=14.0.0",
"mssql": "^11.0.1",
"mysql2": ">=2",
"pg": ">=8",
"postgres": ">=3",
"sql.js": ">=1",
"sqlite3": ">=5",
"typebox": ">=1.0.0",
"valibot": ">=1.0.0-beta.7",
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@aws-sdk/client-rds-data": {
"optional": true
},
"@cloudflare/workers-types": {
"optional": true
},
"@effect/sql-d1": {
"optional": true
},
"@effect/sql-libsql": {
"optional": true
},
"@effect/sql-mysql2": {
"optional": true
},
"@effect/sql-pg": {
"optional": true
},
"@effect/sql-pglite": {
"optional": true
},
"@effect/sql-sqlite-bun": {
"optional": true
},
"@effect/sql-sqlite-do": {
"optional": true
},
"@effect/sql-sqlite-node": {
"optional": true
},
"@effect/sql-sqlite-wasm": {
"optional": true
},
"@electric-sql/pglite": {
"optional": true
},
"@libsql/client": {
"optional": true
},
"@libsql/client-wasm": {
"optional": true
},
"@neondatabase/serverless": {
"optional": true
},
"@op-engineering/op-sqlite": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@planetscale/database": {
"optional": true
},
"@sinclair/typebox": {
"optional": true
},
"@sqlitecloud/drivers": {
"optional": true
},
"@tidbcloud/serverless": {
"optional": true
},
"@tursodatabase/database": {
"optional": true
},
"@tursodatabase/database-common": {
"optional": true
},
"@tursodatabase/database-wasm": {
"optional": true
},
"@tursodatabase/serverless": {
"optional": true
},
"@tursodatabase/sync": {
"optional": true
},
"@types/better-sqlite3": {
"optional": true
},
"@types/mssql": {
"optional": true
},
"@types/pg": {
"optional": true
},
"@types/sql.js": {
"optional": true
},
"@upstash/redis": {
"optional": true
},
"@vercel/postgres": {
"optional": true
},
"@xata.io/client": {
"optional": true
},
"arktype": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"bun-types": {
"optional": true
},
"effect": {
"optional": true
},
"expo-sqlite": {
"optional": true
},
"mssql": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"postgres": {
"optional": true
},
"sql.js": {
"optional": true
},
"sqlite3": {
"optional": true
},
"typebox": {
"optional": true
},
"valibot": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}
@@ -0,0 +1,18 @@
{
"name": "@qinglong/local-operator-image",
"version": "3.0.0-alpha.2",
"private": true,
"description": "Locked build dependencies for the QingLong 3.0 short-lived Local operator image",
"license": "Apache-2.0",
"engines": {
"node": ">=24.18.0 <25"
},
"dependencies": {
"drizzle-orm": "1.0.0-rc.4",
"semver": "7.7.4"
},
"devDependencies": {
"@types/node": "24.13.3",
"typescript": "5.9.3"
}
}
@@ -0,0 +1,31 @@
{
"name": "@qinglong/local-operator-image",
"version": "3.0.0-alpha.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qinglong/local-operator-image",
"version": "3.0.0-alpha.2",
"license": "Apache-2.0",
"dependencies": {
"semver": "7.7.4"
},
"engines": {
"node": ">=24.18.0 <25"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
}
}
}
@@ -0,0 +1,13 @@
{
"name": "@qinglong/local-operator-image",
"version": "3.0.0-alpha.2",
"private": true,
"description": "Production-only external dependency root for the QingLong 3.0 short-lived Local operator image",
"license": "Apache-2.0",
"engines": {
"node": ">=24.18.0 <25"
},
"dependencies": {
"semver": "7.7.4"
}
}
+3 -1
View File
@@ -11,7 +11,9 @@
最新增量证据(2026-08-26):
- Alpha 阶段产物门(已有源码一致的 `3.0.0-alpha.2` 本地可加载候选,远端归档待显式授权):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Docker archiveSHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksumimage identity/architecture/non-root user、read-only/no-network、128 MiB/0.5 CPU smoke、Edge/Standalone lifecycle/graceful stop/SQLite integrity、库存对账与 Trivy 0.70.0 HIGH/CRITICAL=0 已复验。macOS Docker Desktop 无法等价证明的 Local API cancellation 由原生 Linux arm64 job `97986754052` 通过;本机 Edge 首次 startup receipt 在 Docker Desktop 文件桥出现一次瞬态,精确重跑和原生 Linux 门均通过,证据未隐藏首次失败。远端 CI run `32903679764` 首轮为 37/40(两项 GitHub action 内部 DNS 失败、一次 PostgreSQL 18 x64 scheduler 并发断言),failed-only attempt 2 收敛为 40/40;独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration/CNPG/PVC run `32903679570` 同源成功。因尚未授权向 GitHub 上传源码派生镜像/SBOM,当前仍是 owner-private 的单架构阶段产物,不是公开可下载 releaseamd64 同级 archive、public GHCR digest、签名/attestation、catalog、deployment-lock 与生产 HA/DR/CSI/IdP 仍是升级为完整双架构里程碑或 Public Release Set 的硬门。
- Alpha 阶段产物门(已有源码一致的 `3.0.0-alpha.2` 本地 runtime engineering candidate,用户试运行套件正在闭环):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Application Docker archiveSHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksumimage identity/architecture/non-root user、read-only/no-network、128 MiB/0.5 CPU smoke、Edge/Standalone lifecycle/graceful stop/SQLite integrity、库存对账与 Trivy 0.70.0 HIGH/CRITICAL=0 已复验。但该 archive 只有 headless runtime,未携带完成 fresh setup/Owner 管理所需的独立 `ql3` 制品,因此按 D-408 重新准确分类为“运行时工程候选”,不再冒充完整用户 Alpha。macOS Docker Desktop 无法等价证明的 Local API cancellation 由原生 Linux arm64 job `97986754052` 通过;本机 Edge 首次 startup receipt 在 Docker Desktop 文件桥出现一次瞬态,精确重跑和原生 Linux门均通过,证据未隐藏首次失败。远端 CI run `32903679764` 首轮为 37/40(两项 GitHub action 内部 DNS 失败、一次 PostgreSQL 18 x64 scheduler 并发断言),failed-only attempt 2 收敛为 40/40;独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration/CNPG/PVC run `32903679570` 同源成功。因尚未授权向 GitHub 上传源码派生镜像/SBOM,当前仍是 owner-private 的单架构工程产物,不是公开可下载 releaseamd64 同级 archive、public GHCR digest、签名/attestation、catalog、deployment-lock 与生产 HA/DR/CSI/IdP 仍是升级为完整双架构里程碑或 Public Release Set 的硬门。
- D-408/ADR-0503(进行中):阶段产物成熟度现在按真实部署用户旅程而非“已有 Dockerfile/镜像”裁决。新增独立 `qinglong3-local-operator` 短生命周期镜像,复用既有 `@qinglong/local-owner-cli` 的统一 `ql3` 入口而不新增 workspace package;它默认 `65532:65532`、无端口、无 listener/daemon/timer、network none,和常驻 Local Application 保持物理制品分离,因此 Owner/bootstrap authority 不进入 runtime closure,Edge 稳态资源零变化。本机基于未提交工作树构建的 arm64 operator 原型 ID 为 `sha256:115e90a7442b3c92db0c566f8fc8a560e689878b67eace0236836681a14689ae`,运行库存为 9 package/904 files/9,479,647 bytesread-only、drop ALL、no-new-privileges、128 MiB/0.5 CPU/32 PID 下的 `ql3 --version``ql3 setup --help` 已通过。这些数值只证明实现可构建,不冒充 commit-bound release evidence。Alpha workflow 将在同一原生 runner 上把 Application 与 operator 通过一次 `docker image save` 合并为去重的 `qinglong3-local-trial-kit-<arch>.docker.tar`manifest 同时绑定两个 image ID、共同 archive SHA-256、source/version/architecture,并从镜像入口完成 fresh setup exact replay、Identity provision、challenge、首 Owner claim/ack、Application active/SIGTERM drain 和 SQLite integrity。Docker Desktop bind mount 根目录会把宿主 UID 501 映射为容器 root、子文件仍为 501,不能等价满足完整 POSIX lineage;本机失败被记录为平台不等价,未放宽门禁或伪装通过。D-408 转 Accepted 仍需同一提交的原生 Linux x64/arm64 journey 成功和完整回归;实际上传双架构 trial kit 仍需维护者显式授权,Public Release Set 是否正式增加 operator artifact 另行决策。
- D-407/ADR-0502(已完成受审 Kubernetes live ceremony):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact IDCluster Control 按 credential record 精确选一把 key,未知 ID/material 一律 unavailable,绝不 fallback 或遍历,因此认证热路径仍为一次摘要。旧 raw pepper 只通过 `legacy-v1` singleton bridge 保持通用 CLI/进程兼容;Kubernetes Job 和常驻 Cluster Control manifest 已统一为 keyring-only,不再维护第二套单值 Secret 注入模式。新增 `pepper.references` 以数据库时间返回最多 64 个当前 latest active/unexpired credential ID 和 `hasMore`,只作为退休前检查,不执行删除。keyring 文件有 2 KiB、canonical/no-symlink/private/stable-read 边界,无 watcher/timer/新连接池;Edge/Standalone package、依赖与常驻资源零变化。远程 run `32893754795` 在 source `beb490c48c7d8ee4aee629924b5003fd8c73e9cb` 上完成 K3s `v1.34.3+k3s1` 三节点、CloudNativePG 1.30.0 三实例 PostgreSQL 18.4、三次真实双副本反亲和 rollout 和五次 `/api/v3` 认证 probeold/new 在 overlap 期间均认证成功并因无 Project role 返回 403,旧代引用从 1 收敛至 0contract 后 old 返回 401、new 仍返回 403;数据库保留 1 个旧代/3 个新代 credential version、四次授权拒绝与一次认证拒绝。首次远程失败还暴露了 no-symlink 运行时约束与 kubelet Atomic Writer 投影的架构冲突;最终部署用 hardened init container 固定解析一个 `..data` generation,将 CA/keyring 复制成 `0400` Pod-private 普通文件,常驻容器不再读取原始 symlink 投影。完整 backend 为 `1599 total / 1597 pass / 2 conditional skip / 0 fail`,治理/部署聚焦门为 88/88source `f8934b401d724378fe5a6ea9dbe63e696b5480b9` 的远程 CI run `32898407637` 为 40/40CloudNativePG failover、Plugin Package PostgreSQL OCI recovery、Secret rotation、Provider credential K3s/CNPG 等关键 live job 全部通过,独立 Kubernetes deployment run `32898407590` 同样通过。live 报告离线复审 `compatible=true/findings=[]`SHA-256 为 `d9e9fd1395adcef60f7f360959fcad27a9b2f0b132869bc1c75043dedd400ff6`。该门关闭应用合同与权限边界,不冒充生产 control-plane HA、跨主机 STONITH/DR、加密 CSI 或外部 ingress TLSmaterial GC、持久 active catalog、索引/大规模查询计划、远程 UI/API 与双人复核仍是后续门禁。
@@ -0,0 +1,87 @@
# ADR-0503:可下载的 Local Alpha 试运行套件
- 状态:Proposed(实现完成,原生 Linux x64/arm64 CI 与实际归档待验收)
- 日期:2026-08-26
- 决策:D-408
- 关联:ADR-0193、ADR-0195、ADR-0196、ADR-0425
## 背景
`3.0.0-alpha.2` 已经形成可加载的 Local Application arm64 Docker archive、SBOM、checksum、受限资源 smoke 和完整 CI 记录,但该镜像是刻意裁剪的 headless 常驻运行时。Fresh setup、首 Owner ceremony、Task/Trigger/Secret/Package 管理仍依赖工作区或另行安装的 `ql3` CLI。把这份单镜像称为“用户可试运行 Alpha”会混淆两件事:运行时已经可验证,不代表部署用户已经拿到完整操作入口。
不能为追求下载便利而把 Owner 管理代码塞回常驻 Application 镜像。那会让持有 SQLite 数据卷的远程运行时同时获得一次性 bootstrap、credential 和管理 authority,也会让低配设备永久承担未使用的管理闭包。继续只发布 headless 镜像同样不成立,因为下载者无法仅凭 artifact 完成 fresh 初始化。
## 决策
### 1. 阶段成熟度必须按用户旅程裁决
Local 阶段产物分为三档:
1. `runtime_engineering_candidate`:只有可加载、受限资源验证的常驻镜像;可用于开发与设备兼容测试,不得称为完整用户 Alpha;
2. `local_alpha_trial_kit`:同一源码、同一架构的 Application 与短生命周期 operator 同时可下载,能够在无 workspace 依赖下完成 fresh setup、首 Owner ceremony、运行时 active、SIGTERM drain 和 SQLite integrity
3. `public_release_set`:再增加双架构、受保护 tag、签名/attestation、catalog、deployment lock、正式升级与回退承诺。
源码、Dockerfile、单元测试数字或单个 headless archive 都不能越级。
### 2. Operator 是独立镜像,不是新 package 或常驻 sidecar
新增 `deploy/containers/ql3-local-operator`,只把既有 `@qinglong/local-owner-cli` 统一 `ql3` 产品入口及其受审依赖装配为短生命周期 OCI image:
- 默认 UID/GID `65532:65532`,部署者可在 bind mount 场景显式覆盖为最终 POSIX owner
- 默认入口是 `ql3`,每次只执行一个现有 command-file 命令后退出;
-`EXPOSE`、listener、daemon、timer、watcher、Pool 或第二个 SQLite 常驻连接;
- 运行约束固定为 read-only root、network none、drop ALL、no-new-privileges
- image label 明确 `lifecycle=short-lived``authority=local-owner-management``network=none-by-default`
- 不新增 workspace package,不修改现有 18-package 领域边界,也不进入 Local Application dependency closure。
Operator 保留 AI/Secret/Package 等 `ql3` 管理子命令所需的完整受审 JS;它是一次性管理制品,不能把其 9.48 MiB package inventory 或约 171 MB 单独镜像虚拟大小算作 Edge 稳态 RSS。文件门为 1,024、包内容门为 12 MiB,后续增长必须显式审计。
### 3. 同架构两镜像使用一个去重 archive
Alpha workflow 在同一原生 runner 上构建 Application 与 operator,验证 source revision、版本、架构和非 root identity 后,用一次 `docker image save` 写入同一个 `qinglong3-local-trial-kit-<arch>.docker.tar`。这样共享 Node 基础层只在 archive 中保存一次,避免路由/NAS 用户下载两个重复基础层。
manifest 升为 `qinglong/alpha-local-trial-kit@v1`,同时绑定两个 image tag、image ID、共同 archive SHA-256、版本、完整 source revision、架构和已通过门。两镜像 source/version/architecture 任一不一致都失败关闭。
普通 push/PR 只构建和验证,不上传 archive。只有显式 `workflow_dispatch + produce_alpha_artifacts=true` 才产生 30 天 owner-visible artifact;该动作仍不是公开 GHCR release。
### 4. 用户旅程必须从镜像入口完成
`ql3-local-alpha-trial-kit-live-contract.cjs` 不允许从 workspace 调用 setup 或 Owner service。它只通过 operator image 的 `ql3 setup``ql3 owner` 命令完成:
1. fresh SQLite、Owner pepper keyring/backup 和 Local Secret keyring
2. setup response-loss exact replay
3. identity provision、challenge、首 Owner claim 与两份 delivery acknowledgement
4. 同一数据根启动 Application,等待 `event=active`
5. SIGTERM 后等待 `event=stopped`,复核 SQLite integrity 与唯一 active Owner binding。
Edge 使用 128 MiB、0.5 CPU、64 PIDStandalone 使用 256 MiB、0.5 CPU、256 PIDoperator 固定 128 MiB、0.5 CPU、32 PID。所有容器均 read-only、network none、drop ALL、no-new-privileges。
Docker Desktop 的 bind mount 根目录可能把宿主当前 UID 映射为容器内 root,而子文件仍保留宿主 UID,无法等价满足 Owner console 的完整 POSIX lineage。此平台失败必须记录为 `platform_not_equivalent`,不能放宽权限检查;正式裁决来自原生 Linux x64/arm64 runner。
## 被拒绝的替代方案
### 把 `ql3` CLI 合入常驻 Application 镜像
拒绝。镜像内容即潜在可调用能力;持有运行数据库的远程进程不应同时携带 bootstrap、credential 与管理 authority。
### 新建 `local-operator` workspace package
拒绝。部署边界由现有 `local-owner-cli` 已经表达;新增只含一个入口的 package 会重新制造浅包,而不能增加权限隔离。
### 分别保存两个 Docker archive
拒绝。两个镜像共享相同 pinned Node runtime layer,分开保存会让低容量设备和离线分发重复付费。
### 因 Docker Desktop UID 差异放宽 Owner POSIX proof
拒绝。开发机便利不能削弱生产本机身份根。平台差异由 native Linux 门解决。
## 当前验证与剩余门
- operator 静态契约与突变测试 `2/2`;既有 Local image audit 继续 compatible
- 本机基于未提交工作树构建的 arm64 operator 原型 image ID 为 `sha256:115e90a7442b3c92db0c566f8fc8a560e689878b67eace0236836681a14689ae`,默认 `65532:65532`CLI `3.0.0-alpha.2`;该 ID 只证明构建可行性,不是 commit-bound release evidence
- 运行库存为 9 package、904 files、9,479,647 bytes,低于 1,024 files/12 MiB
- `ql3 --version``ql3 setup --help` 在 read-only、network none、128 MiB、0.5 CPU、32 PID 下通过;
- Docker Desktop 完整旅程因 mount root UID 非等价失败,临时 credential/pepper 目录已删除;未把该结果记为通过。
转为 Accepted 前必须取得同一提交的原生 Linux amd64/arm64 完整旅程成功记录,并重新执行 package、backend、artifact、dependency 和 release workflow 审计。实际双架构 trial-kit archive 仍需维护者明确授权手动生成;Public Release Set 是否把 operator 纳入正式签名/catalog,留给后续独立 release-set schema 决策。
+1
View File
@@ -506,6 +506,7 @@
| [ADR-0500](./ADR-0500-short-lived-cluster-security-administration-command.md) | 短生命周期 Cluster Security Administration 产品命令 | Accepted |
| [ADR-0501](./ADR-0501-opt-in-kubernetes-security-administration-job.md) | 可选的一次性 Kubernetes Security Administration Job | Accepted |
| [ADR-0502](./ADR-0502-bounded-cluster-api-credential-pepper-keyring.md) | 有界 Cluster API Credential Pepper Keyring | Accepted |
| [ADR-0503](./ADR-0503-downloadable-local-alpha-trial-kit.md) | 可下载的 Local Alpha 试运行套件 | Proposed(实现完成;原生 Linux 双架构与实际归档待验收) |
## 规则
+21 -9
View File
@@ -6,15 +6,16 @@
| 等级 | 面向对象 | 必须通过 | 当前用途 |
| --- | --- | --- | --- |
| Local Alpha Candidate | amd64/arm64 路由器、NAS、单机 | OS 漏洞策略、SBOM 与镜像库存复核、128 MiB entrypoint、Edge/Standalone fresh SQLite lifecycle、真实本机 API cancellation | 下载 Docker archive,核验后进行隔离试运行与设备兼容测试 |
| Runtime Engineering Candidate | QingLong 开发者、设备兼容测试者 | 单个常驻镜像的 OS 漏洞策略、SBOM/库存、资源门和生命周期 | 验证 runtime 可加载、可启动;缺少管理制品时不能称用户 Alpha |
| Local Alpha Trial Kit | amd64/arm64 路由器、NAS、单机试用者 | 同源 Application + 短生命周期 operator、fresh setup/Owner/active/stop 完整旅程、SBOM/库存与资源门 | 一个去重 Docker archive 完成隔离 fresh 试运行;不承诺生产升级 |
| Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identityAdmin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release |
| Public Release Set | 生产用户 | 受保护 tag、五镜像 multi-arch digest、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 |
只有第一等级可以称为本阶段“用户可试运行产物”。Cluster archive 是工程集成产物,因为离线 per-architecture tag 不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
## 当前阶段实物(2026-08-26
当前已经存在一份与 `3.0.0-alpha.2` 源码身份一致、owner-private、可重新加载的 Local arm64 候选,而不是只有源码或 Dockerfile:
当前已经存在一份与 `3.0.0-alpha.2` 源码身份一致、owner-private、可重新加载的 Local arm64 runtime engineering candidate,而不是只有源码或 Dockerfile:
- source revision`e3c05862b8c2690d69f58b098cdc128a09c83f97`
- image`qinglong3-local-application:alpha2-e3c05862-arm64`image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`
@@ -23,7 +24,9 @@
- HIGH/CRITICAL OS vulnerability 为 0128 MiB、0.5 CPU、read-only、no-network、drop-all 下的 Edge/Standalone fresh lifecycle、graceful stop 与 SQLite integrity 已通过;本机 Edge 首次运行曾在 Docker Desktop 文件桥上出现一次 startup receipt 发布瞬态,精确重跑通过,未将首次失败隐藏为成功;
- 原生 Linux arm64 Local image job `97986754052` 已覆盖 Docker Desktop 无法等价证明的 Local API cancellation。完整 CI run `32903679764` attempt 2 为 40/40,独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration run `32903679570` 同源通过。首轮 CI 的两项 `pnpm/action-setup` 内部 DNS 失败和一次 PostgreSQL 18 x64 scheduler 并发断言均在 failed-only rerun 收敛。
该实物保存在工作区忽略目录,不进入 Git,也尚未上传 GitHub。公开下载仍需维护者明确授权上传。它足以作为单架构内部试运行材料,但在 amd64 同级 archive 和远端 artifact identity 未齐全前,不得把它升级为完整双架构阶段里程碑或公开 release
该实物保存在工作区忽略目录,不进入 Git,也尚未上传 GitHub。公开下载仍需维护者明确授权上传。它只含 headless Application,没有可下载的 `ql3 setup/owner/task/...` 管理制品;因此它足以证明 runtime 工程可用性,但不能独立完成部署用户旅程。此前“单架构内部试运行材料”的表述按 D-408 收紧为“运行时工程候选”
ADR-0503 已增加独立的 `qinglong3-local-operator`:它复用现有统一 `ql3` CLI,每次执行一个 command-file 命令后退出,不进入常驻 Application。后续手动 Alpha run 会把 Application 与 operator 通过一次 `docker image save` 写入同一架构的去重 archive;只有原生 Linux amd64/arm64 都完成 fresh setup、首 Owner ceremony、Application active/stop 和 SQLite integrity 后,才能升级为 Local Alpha Trial Kit。
## 生成
@@ -34,13 +37,15 @@
- `ql3-alpha-<commit>-local-amd64``ql3-alpha-<commit>-local-arm64`
- `ql3-alpha-<commit>-control-<arch>``control-ai-<arch>``admin-<arch>``worker-<arch>`
每个 artifact 含:
Local artifact 含:
- 通过对应测试的 native Docker archive
- `manifest.json`,绑定版本、完整 source commit、架构、原始 image tagimage ID、archive SHA-256 与已通过 gate
- 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-<arch>.docker.tar`;共享 Node 基础层在 archive 中去重
- schema 为 `qinglong/alpha-local-trial-kit@v1` `manifest.json`,绑定版本、完整 source commit、架构、两个 image tag/image ID、共同 archive SHA-256 与已通过 gate
- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM
- 本说明。
Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manifest。
任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。
## 下载后验证与最小 smoke
@@ -57,15 +62,22 @@ docker load --input "${archive}"
image="$(node -p "require('./manifest.json').image")"
expected_id="$(node -p "require('./manifest.json').imageId")"
test "$(docker image inspect --format '{{.Id}}' "${image}")" = "${expected_id}"
operator_image="$(node -p "require('./manifest.json').operator.image")"
operator_expected_id="$(node -p "require('./manifest.json').operator.imageId")"
test "$(docker image inspect --format '{{.Id}}' "${operator_image}")" = "${operator_expected_id}"
docker run --rm --read-only --network none --cap-drop ALL \
--security-opt no-new-privileges "${image}" --help
docker run --rm --read-only --network none --cap-drop ALL \
--security-opt no-new-privileges "${operator_image}" --version
docker run --rm --read-only --network none --cap-drop ALL \
--security-opt no-new-privileges "${operator_image}" setup --help
```
下载页本身不是 source identity;还必须把 `manifest.json.sourceRevision` 与预期 `next` commit 对齐。不要在生产数据库、生产 Secret 或 2.x 唯一数据目录上直接试用。
## 试运行与回退边界
Local 正式部署仍应遵循 [Edge/Standalone 部署准备](./ql3-local-deployment.md),先做 fresh 私有目录/数据库/Owner authority,再执行受审配置、preflight 和 rollout。Alpha Docker archive 只替代“待测镜像来源”,不会替操作者生成 pepper、credential数据库备份或 2.x cutover evidence
Local 正式部署仍应遵循 [Edge/Standalone 部署准备](./ql3-local-deployment.md)。Trial Kit 中的 operator 可以从受审命令文件生成 fresh pepper、credential数据库,但不会猜测部署路径、mutation ID、POSIX owner,也不会替操作者生成 2.x cutover evidence。使用 bind mount 时必须以最终文件 owner 的 UID/GID 运行 operatorDocker Desktop 的 mount-root UID 语义不等价于原生 Linux,失败时不得放宽 Owner proof
阶段试运行必须使用独立目录和独立数据库;回退的最低保证是停止并删除 Alpha 容器、保留测试目录用于诊断,然后回到未被修改的 2.x 实例。凡是执行 2.x→3.0 数据迁移或 3.0 写入后切回,都必须走既有 reconciliation/cutover/rollback ceremony,不能只换镜像。
@@ -73,4 +85,4 @@ Cluster candidate 必须先导入隔离 registry 并重新绑定该 registry 的
## 里程碑判定
一次阶段里程碑只有同时记录以下事实才成立:源码 commit、版本、两种 Tier-1 架构所需产物、完整 CI run、artifact 名与 digest、至少一个目标 Profile smoke、已知限制和回退路径。仅有源码、`dist/`、单元测试数字、Dockerfile 或“理论上可构建”都不算阶段性可用产物。
一次用户阶段里程碑只有同时记录以下事实才成立:源码 commit、版本、两种 Tier-1 架构的 Application/operator、完整 CI run、artifact 名与 digest、fresh setup→首 Owner→active→stop 的目标 Profile smoke、已知限制和回退路径。仅有源码、`dist/`、单元测试数字、Dockerfile、单个 headless runtime 或“理论上可构建”都不算用户可用产物。
+2
View File
@@ -38,6 +38,7 @@
"test:run-management-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && pnpm --filter @qinglong/cluster-control build && node scripts/ql3-run-management-kubernetes-live-contract.cjs",
"audit:run-management-kubernetes-live:ql3": "node scripts/ql3-run-management-kubernetes-live-audit.cjs",
"test:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-contract.cjs",
"test:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-live-contract.cjs",
"audit:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-audit.cjs",
"test:provider-credential-test-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-provider-credential-test-kubernetes-live-contract.cjs",
"audit:provider-credential-test-kubernetes-live:ql3": "node scripts/ql3-provider-credential-test-kubernetes-live-audit.cjs",
@@ -152,6 +153,7 @@
"audit:artifact:edge-mcp:ql3": "node scripts/ql3-local-profile-artifact-audit.cjs edge-mcp",
"audit:artifact:standalone-mcp:ql3": "node scripts/ql3-local-profile-artifact-audit.cjs standalone-mcp",
"audit:local-image:ql3": "node scripts/ql3-local-image-audit.cjs",
"audit:local-operator-image:ql3": "node scripts/ql3-local-operator-image-audit.cjs",
"test:local-compose-preflight:ql3": "node scripts/ql3-local-compose-preflight-live-contract.cjs",
"test:local-compose-rollout:ql3": "node scripts/ql3-local-compose-rollout-live-contract.cjs",
"test:local-reconciliation-capture:ql3": "node scripts/ql3-local-reconciliation-capture-docker-gate.cjs",
+23 -1
View File
@@ -76,6 +76,28 @@ const IMAGE_PROFILES = Object.freeze({
'drizzle-orm': '1.0.0-rc.4',
}),
}),
'local-operator': Object.freeze({
id: 'local-operator',
buildManifestPath: 'deploy/containers/ql3-local-operator/package.json',
buildLockPath: 'deploy/containers/ql3-local-operator/package-lock.json',
imageManifestPath:
'deploy/containers/ql3-local-operator/runtime-dependencies/package.json',
imageLockPath:
'deploy/containers/ql3-local-operator/runtime-dependencies/package-lock.json',
internalManifestPaths: Object.freeze([
'packages/ql3-runtime-core/package.json',
'packages/ql3-ai/package.json',
'packages/ql3-local-admin/package.json',
'packages/ql3-local-command-file/package.json',
'packages/ql3-local-owner-cli/package.json',
'packages/ql3-local-owner-console/package.json',
'packages/ql3-local-secret/package.json',
'packages/ql3-local-sqlite/package.json',
]),
buildOnlyDependencies: Object.freeze({
'drizzle-orm': '1.0.0-rc.4',
}),
}),
worker: Object.freeze({
id: 'worker',
buildManifestPath: 'deploy/containers/ql3-worker/package.json',
@@ -110,7 +132,7 @@ function resolveImageProfile(value = 'control') {
const profile = IMAGE_PROFILES[value];
if (!profile) {
throw new Error(
'image profile must be exactly control, control-ai, admin, local or worker',
'image profile must be exactly control, control-ai, admin, local, local-operator or worker',
);
}
return profile;
@@ -0,0 +1,509 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const { DatabaseSync } = require('node:sqlite');
const MAX_OUTPUT_BYTES = 64 * 1024;
const ACTIVE_TIMEOUT_MS = 45_000;
const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,511}$/;
function fail(message) {
throw new Error(`QingLong Local Alpha trial kit failed: ${message}`);
}
function argumentsFrom(argv) {
const values = {};
for (const argument of argv) {
const match = /^--(application-image|operator-image|profile)=(.+)$/u.exec(
argument,
);
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
if (
Object.keys(values).length !== 3 ||
!IMAGE_PATTERN.test(values['application-image'] ?? '') ||
!IMAGE_PATTERN.test(values['operator-image'] ?? '') ||
!['edge', 'standalone'].includes(values.profile)
) {
fail(
'usage: --application-image=... --operator-image=... --profile=edge|standalone',
);
}
return Object.freeze({
applicationImage: values['application-image'],
operatorImage: values['operator-image'],
profile: values.profile,
});
}
function docker(args, options = {}) {
const result = spawnSync('docker', args, {
encoding: 'utf8',
maxBuffer: MAX_OUTPUT_BYTES,
...options,
});
if (result.error) throw result.error;
if (result.status !== 0) {
fail(
`docker ${args[0]} failed: ${(result.stderr || result.stdout)
.trim()
.slice(0, 2048)}`,
);
}
return result.stdout.trim();
}
function inspectImages(applicationImage, operatorImage) {
const application = docker([
'image',
'inspect',
'--format',
'{{.Id}} {{.Architecture}} {{.Config.User}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}',
applicationImage,
]).split(' ');
const operator = docker([
'image',
'inspect',
'--format',
'{{.Id}} {{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.lifecycle"}} {{index .Config.Labels "io.qinglong.authority"}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}',
operatorImage,
]).split(' ');
if (
!/^sha256:[0-9a-f]{64}$/u.test(application[0] ?? '') ||
!/^sha256:[0-9a-f]{64}$/u.test(operator[0] ?? '') ||
application[1] !== operator[1] ||
!['amd64', 'arm64'].includes(application[1]) ||
application[2] !== '65532:65532' ||
operator[2] !== '65532:65532' ||
operator[3] !== 'short-lived' ||
operator[4] !== 'local-owner-management' ||
!/^[0-9a-f]{40}$/u.test(application[3] ?? '') ||
application[3] !== operator[5] ||
application[4] !== operator[6] ||
!/^3\.0\.0-alpha\.[0-9]+$/u.test(application[4] ?? '')
) {
fail('image identity, architecture or authority labels drifted');
}
return Object.freeze({
architecture: application[1],
applicationId: application[0],
operatorId: operator[0],
});
}
function writePrivateJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
}
function operatorArguments(state, command, ...argv) {
return [
'run',
'--rm',
'--read-only',
'--user',
`${state.uid}:${state.gid}`,
'--network',
'none',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--memory',
'128m',
'--memory-swap',
'128m',
'--cpus',
'0.5',
'--pids-limit',
'32',
'--tmpfs',
'/tmp:rw,nosuid,nodev,noexec,size=8m',
'--volume',
`${state.root}:/var/lib/qinglong3`,
state.operatorImage,
command,
...argv,
];
}
function runOperator(state, command, commandFileName) {
let output;
try {
output = docker(
operatorArguments(
state,
command,
'run',
'--command-file',
`/var/lib/qinglong3/${commandFileName}`,
),
);
} catch (error) {
fail(
`operator stage ${command}/${commandFileName} failed: ${
error instanceof Error ? error.message : 'unknown failure'
}`,
);
}
let result;
try {
result = JSON.parse(output);
} catch {
fail('operator emitted non-JSON output');
}
if (!result || typeof result !== 'object' || Array.isArray(result)) {
fail('operator result shape is invalid');
}
return result;
}
function ownerCommand(state, fileName, operation, request) {
writePrivateJson(path.join(state.root, fileName), {
schemaVersion: 1,
operation,
options: {
deploymentRoot: '/var/lib/qinglong3',
databasePath: '/var/lib/qinglong3/qinglong3.sqlite',
pepperPath: '/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper',
pepperKeyId: 'owner-v1',
secretDeliveryDirectory: '/var/lib/qinglong3/owner-delivery',
profile: state.profile,
busyTimeoutMs: 100,
},
request,
});
return runOperator(state, 'owner', fileName);
}
function prepareFreshAuthority(state) {
for (const directory of [
'owner-peppers',
'owner-pepper-backup',
'owner-delivery',
'receipts',
'artifacts',
'plugin-staging',
'plugin-activation',
]) {
fs.mkdirSync(path.join(state.root, directory), { mode: 0o700 });
}
writePrivateJson(path.join(state.root, 'setup.json'), {
schemaVersion: 1,
operation: 'local.setup.prepare',
options: {
deploymentRoot: '/var/lib/qinglong3',
databasePath: '/var/lib/qinglong3/qinglong3.sqlite',
profile: state.profile,
ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers',
ownerPepperBackupDirectory: '/var/lib/qinglong3/owner-pepper-backup',
ownerPepperKeyId: 'owner-v1',
localSecretKeyringPath: '/var/lib/qinglong3/local-secret-keyring.json',
busyTimeoutMs: 100,
},
request: {
registerMutationId: '019f8680-143d-4000-8000-000000000011',
activateMutationId: '019f8680-143d-4000-8000-000000000012',
registeredAtMs: 1_785_254_400_000,
activatedAtMs: 1_785_254_400_001,
},
});
const prepared = runOperator(state, 'setup', 'setup.json');
const replay = runOperator(state, 'setup', 'setup.json');
if (prepared.status !== 'prepared' || replay.status !== 'existing') {
fail('fresh setup did not converge through the operator image');
}
return Object.freeze({ prepared: true, replay: true });
}
function establishFirstOwner(state) {
const credentialMutationId = '019f8680-143d-4000-8000-000000000021';
const challengeMutationId = '019f8680-143d-4000-8000-000000000022';
const provisioned = ownerCommand(
state,
'owner-provision.json',
'owner.identity.provision',
{
mutationId: credentialMutationId,
requestId: 'alpha-trial-owner-provision',
},
);
const issued = ownerCommand(
state,
'owner-challenge.json',
'owner.challenge.issue',
{
projectId: 'default',
mutationId: challengeMutationId,
requestId: 'alpha-trial-owner-challenge',
},
);
const claimed = ownerCommand(
state,
'owner-claim.json',
'owner.claim.from-deliveries',
{
projectId: 'default',
mutationId: '019f8680-143d-4000-8000-000000000023',
requestId: 'alpha-trial-owner-claim',
credentialMutationId,
challengeMutationId,
},
);
if (
provisioned.status !== 'inserted' ||
issued.status !== 'inserted' ||
claimed.status !== 'inserted' ||
claimed.role !== 'owner'
) {
fail('first Owner ceremony did not converge');
}
for (const acknowledgement of [
{
file: 'owner-credential-ack.json',
purpose: 'credential-provisioning',
mutationId: credentialMutationId,
digest: provisioned.delivery?.deliveryDigest,
},
{
file: 'owner-challenge-ack.json',
purpose: 'challenge',
mutationId: challengeMutationId,
digest: issued.delivery?.deliveryDigest,
},
]) {
if (!/^[0-9a-f]{64}$/u.test(acknowledgement.digest ?? '')) {
fail('Owner delivery digest is unavailable');
}
ownerCommand(state, acknowledgement.file, 'owner.delivery.acknowledge', {
purpose: acknowledgement.purpose,
mutationId: acknowledgement.mutationId,
expectedDeliveryDigest: acknowledgement.digest,
});
}
return Object.freeze({
provisioned: true,
challenged: true,
claimed: true,
acknowledged: true,
});
}
function writeApplicationConfig(state) {
writePrivateJson(path.join(state.root, 'local-application.json'), {
schema: 'qinglong/local-application-process@v2',
instanceId: 'alpha-trial-local',
profile: state.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' },
});
}
async function runApplication(state) {
const name = `ql3-alpha-trial-${process.pid}-${crypto
.randomUUID()
.slice(0, 8)}`;
const memory = state.profile === 'edge' ? '128m' : '256m';
const child = spawn(
'docker',
[
'run',
'--rm',
'--name',
name,
'--read-only',
'--user',
`${state.uid}:${state.gid}`,
'--network',
'none',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--memory',
memory,
'--memory-swap',
memory,
'--cpus',
'0.5',
'--pids-limit',
state.profile === 'edge' ? '64' : '256',
'--tmpfs',
'/tmp:rw,nosuid,nodev,noexec,size=16m',
'--volume',
`${state.root}:/var/lib/qinglong3`,
state.applicationImage,
'--config',
'/var/lib/qinglong3/local-application.json',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let stdout = '';
let stderr = '';
let active = false;
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
if (
!active &&
stdout.split('\n').some((line) => {
try {
return JSON.parse(line).event === 'active';
} catch {
return false;
}
})
) {
active = true;
docker(['stop', '--time', '30', name]);
}
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
try {
const outcome = await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('application lifecycle timed out')),
ACTIVE_TIMEOUT_MS,
);
child.once('error', reject);
child.once('exit', (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
const events = stdout
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line));
if (
outcome.code !== 0 ||
outcome.signal !== null ||
stderr !== '' ||
!active ||
!events.some(
({ event, stopResult }) =>
event === 'stopped' && stopResult === 'stopped',
)
) {
fail(
`application lifecycle drifted: ${JSON.stringify({
outcome,
stderr: stderr.slice(0, 2048),
events,
})}`,
);
}
return Object.freeze({ active: true, gracefulStop: true });
} finally {
spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' });
}
}
async function main() {
if (process.versions.node.split('.')[0] !== '24') fail('Node 24 is required');
if (
typeof process.getuid !== 'function' ||
typeof process.getgid !== 'function'
) {
fail('a POSIX identity is required');
}
const options = argumentsFrom(process.argv.slice(2));
const images = inspectImages(options.applicationImage, options.operatorImage);
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-trial-')),
);
fs.chmodSync(root, 0o700);
const state = Object.freeze({
...options,
...images,
root,
uid: process.getuid(),
gid: process.getgid(),
});
try {
const setup = prepareFreshAuthority(state);
const owner = establishFirstOwner(state);
writeApplicationConfig(state);
const lifecycle = await runApplication(state);
const database = new DatabaseSync(path.join(root, 'qinglong3.sqlite'), {
readOnly: true,
});
let integrity;
let ownerCount;
try {
integrity = database
.prepare('PRAGMA integrity_check')
.get().integrity_check;
ownerCount = database
.prepare(
`SELECT COUNT(*) AS count FROM "QingLong3ProjectRoleBindings" WHERE "project_id" = 'default' AND "role" = 'owner' AND "state" = 'active'`,
)
.get().count;
} finally {
database.close();
}
if (integrity !== 'ok' || ownerCount !== 1)
fail('durable SQLite result is invalid');
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
schema: 'qinglong/local-alpha-trial-kit-live@v1',
profile: options.profile,
architecture: images.architecture,
images: {
applicationId: images.applicationId,
operatorId: images.operatorId,
},
setup,
owner,
lifecycle,
sqliteIntegrity: integrity,
activeOwnerBindings: ownerCount,
operatorNetwork: 'none',
compatible: true,
})}\n`,
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const IMAGE_DIRECTORY = 'deploy/containers/ql3-local-operator';
const BUILD_NODE_IMAGE =
'node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d';
const RUNTIME_NODE_IMAGE =
'node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436';
const BUILD_DEPENDENCIES = Object.freeze({
'drizzle-orm': '1.0.0-rc.4',
semver: '7.7.4',
});
const BUILD_DEV_DEPENDENCIES = Object.freeze({
'@types/node': '24.13.3',
typescript: '5.9.3',
});
const RUNTIME_DEPENDENCIES = Object.freeze({ semver: '7.7.4' });
const INTERNAL_PACKAGES = Object.freeze([
'ql3-ai',
'ql3-local-admin',
'ql3-local-command-file',
'ql3-local-owner-cli',
'ql3-local-owner-console',
'ql3-local-secret',
'ql3-local-sqlite',
'ql3-runtime-core',
]);
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function same(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function sorted(value) {
return Object.fromEntries(
Object.entries(value ?? {}).sort(([left], [right]) =>
left.localeCompare(right),
),
);
}
function finding(findings, code, detail) {
findings.push(Object.freeze({ code, ...(detail ? { detail } : {}) }));
}
function auditManifest(manifest, release, runtime, findings) {
if (
manifest.name !== '@qinglong/local-operator-image' ||
manifest.version !== release.version ||
manifest.private !== true ||
manifest.license !== 'Apache-2.0' ||
manifest.engines?.node !== release.node.engine
) {
finding(
findings,
runtime ? 'RUNTIME_MANIFEST_IDENTITY' : 'BUILD_MANIFEST_IDENTITY',
);
}
if (
!same(
sorted(manifest.dependencies),
sorted(runtime ? RUNTIME_DEPENDENCIES : BUILD_DEPENDENCIES),
)
) {
finding(
findings,
runtime ? 'RUNTIME_DEPENDENCY_DRIFT' : 'BUILD_DEPENDENCY_DRIFT',
);
}
if (
!same(
sorted(manifest.devDependencies),
sorted(runtime ? {} : BUILD_DEV_DEPENDENCIES),
)
) {
finding(
findings,
runtime ? 'RUNTIME_DEV_DEPENDENCY_PRESENT' : 'BUILD_DEV_DEPENDENCY_DRIFT',
);
}
}
function auditLock(manifest, lock, runtime, findings) {
const root = lock.packages?.[''];
if (
lock.lockfileVersion !== 3 ||
lock.requires !== true ||
root?.name !== manifest.name ||
root?.version !== manifest.version ||
!same(sorted(root?.dependencies), sorted(manifest.dependencies)) ||
!same(sorted(root?.devDependencies), sorted(manifest.devDependencies))
) {
finding(
findings,
runtime ? 'RUNTIME_LOCK_ROOT_DRIFT' : 'BUILD_LOCK_ROOT_DRIFT',
);
}
for (const [packagePath, entry] of Object.entries(lock.packages ?? {})) {
if (packagePath === '') continue;
if (
typeof entry.version !== 'string' ||
typeof entry.integrity !== 'string' ||
!entry.integrity.startsWith('sha512-') ||
typeof entry.resolved !== 'string' ||
!entry.resolved.startsWith('https://registry.npmjs.org/') ||
entry.hasInstallScript === true ||
entry.link === true
) {
finding(findings, 'LOCKED_PACKAGE_UNSAFE', packagePath);
}
}
if (
runtime &&
!same(Object.keys(lock.packages ?? {}).sort(), ['', 'node_modules/semver'])
) {
finding(findings, 'RUNTIME_LOCK_CLOSURE_DRIFT');
}
}
function auditDockerfile(contents, release, findings) {
const copies = [
...contents.matchAll(/^COPY packages\/(ql3-[a-z-]+) packages\/\1$/gmu),
]
.map((match) => match[1])
.sort();
const copiedRuntimePackages = [
...contents.matchAll(
/^COPY --from=workspace \/workspace\/packages\/(ql3-[a-z-]+)\/(?:package\.json|dist) /gmu,
),
].map((match) => match[1]);
const counts = Object.fromEntries(INTERNAL_PACKAGES.map((name) => [name, 0]));
for (const name of copiedRuntimePackages)
counts[name] = (counts[name] ?? 0) + 1;
if (!same(copies, [...INTERNAL_PACKAGES].sort())) {
finding(findings, 'BUILD_PACKAGE_CLOSURE_DRIFT');
}
if (
!same(
sorted(counts),
sorted(Object.fromEntries(INTERNAL_PACKAGES.map((name) => [name, 2]))),
)
) {
finding(findings, 'RUNTIME_INTERNAL_PACKAGE_CLOSURE_DRIFT');
}
const required = [
`FROM ${BUILD_NODE_IMAGE} AS dependency-manifest`,
`FROM ${RUNTIME_NODE_IMAGE} AS runtime`,
'RUN npm ci --ignore-scripts --no-audit --no-fund',
'RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund',
`org.opencontainers.image.version="${release.version}"`,
'io.qinglong.lifecycle="short-lived"',
'io.qinglong.authority="local-owner-management"',
'io.qinglong.network="none-by-default"',
'USER 65532:65532',
'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-owner-cli/dist/product-cli/cli.js"]',
'RUN rm -rf node_modules/.bin',
'find node_modules/@qinglong -type f',
"-name '*.d.ts' -o -name '*.map'",
];
for (const value of required) {
if (!contents.includes(value))
finding(findings, 'DOCKERFILE_CONTRACT_DRIFT', value);
}
if (
/(?:^|\n)\s*ARG\s+NODE_IMAGE\b/u.test(contents) ||
/\b(?:apt-get|apt|curl|wget)\b|ADD\s+https?:/iu.test(contents) ||
/^(?:EXPOSE|HEALTHCHECK)\b/gmu.test(contents)
) {
finding(findings, 'UNREVIEWED_RUNTIME_OR_BUILD_SURFACE');
}
}
function auditWorkflow(contents, findings) {
const required = [
'qinglong3-local-operator:ci-${{ matrix.image_arch }}',
'--file deploy/containers/ql3-local-operator/Dockerfile',
'pnpm audit:local-operator-image:ql3',
'scripts/ql3-local-operator-image-inventory.cjs',
'--image=local-operator',
'ql3-local-operator.cdx.json',
'image-ref: qinglong3-local-operator:ci-${{ matrix.image_arch }}',
'"${OPERATOR_IMAGE}" --version',
'scripts/ql3-local-alpha-trial-kit-live-contract.cjs',
'qinglong3-local-trial-kit-${IMAGE_ARCH}.docker.tar',
"schema: 'qinglong/alpha-local-trial-kit@v1'",
'operatorImageId',
"freshOwnerJourney: 'passed'",
];
for (const value of required) {
if (!contents.includes(value))
finding(findings, 'LOCAL_OPERATOR_CI_CONTRACT_DRIFT', value);
}
}
function auditLocalOperatorImageContract(root) {
const resolvedRoot = path.resolve(root);
const release = readReleaseIdentity(resolvedRoot);
const imageRoot = path.join(resolvedRoot, IMAGE_DIRECTORY);
const buildManifest = readJson(path.join(imageRoot, 'package.json'));
const runtimeManifest = readJson(
path.join(imageRoot, 'runtime-dependencies/package.json'),
);
const findings = [];
auditManifest(buildManifest, release, false, findings);
auditManifest(runtimeManifest, release, true, findings);
auditLock(
buildManifest,
readJson(path.join(imageRoot, 'package-lock.json')),
false,
findings,
);
auditLock(
runtimeManifest,
readJson(path.join(imageRoot, 'runtime-dependencies/package-lock.json')),
true,
findings,
);
auditDockerfile(
fs.readFileSync(path.join(imageRoot, 'Dockerfile'), 'utf8'),
release,
findings,
);
auditWorkflow(
fs.readFileSync(
path.join(resolvedRoot, '.github/workflows/ql3-ci.yml'),
'utf8',
),
findings,
);
return Object.freeze({
schemaVersion: 1,
image: 'local-operator',
lifecycle: 'short-lived',
authority: 'local-owner-management',
runtimePackages: Object.freeze(
[
...INTERNAL_PACKAGES.map((name) => `@qinglong/${name.slice(4)}`),
'semver',
].sort(),
),
findings: Object.freeze(findings),
compatible: findings.length === 0,
});
}
module.exports = { auditLocalOperatorImageContract };
if (require.main === module) {
try {
const report = auditLocalOperatorImageContract(
path.resolve(__dirname, '..'),
);
process.stdout.write(`${JSON.stringify(report)}\n`);
if (!report.compatible) process.exitCode = 1;
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
}
@@ -0,0 +1,128 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const EXPECTED_PACKAGES = Object.freeze([
'@qinglong/ai',
'@qinglong/local-admin',
'@qinglong/local-command-file',
'@qinglong/local-owner-cli',
'@qinglong/local-owner-console',
'@qinglong/local-secret',
'@qinglong/local-sqlite',
'@qinglong/runtime-core',
'semver',
]);
const MAX_FILES = 1024;
const MAX_BYTES = 12 * 1024 * 1024;
function fail(message) {
throw new Error(`QingLong local operator 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 packages = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
if (!entry.isDirectory() || entry.isSymbolicLink())
fail('unexpected root entry');
if (!entry.name.startsWith('@')) {
packages.push(entry.name);
continue;
}
for (const child of fs.readdirSync(path.join(root, entry.name), {
withFileTypes: true,
})) {
if (
child.name.startsWith('.') ||
!child.isDirectory() ||
child.isSymbolicLink()
) {
fail('unexpected scoped entry');
}
packages.push(`${entry.name}/${child.name}`);
}
}
return packages.sort();
}
function usage(root) {
const pending = [root];
let files = 0;
let bytes = 0;
while (pending.length > 0) {
for (const entry of fs.readdirSync(pending.pop(), {
withFileTypes: true,
})) {
const entryPath = path.join(entry.parentPath ?? entry.path, entry.name);
const stat = fs.lstatSync(entryPath);
if (stat.isSymbolicLink()) fail('inventory contains a symlink');
if (stat.isDirectory()) pending.push(entryPath);
else if (stat.isFile()) {
files += 1;
bytes += stat.size;
} else fail('inventory contains a special file');
if (files > MAX_FILES || bytes > MAX_BYTES)
fail('inventory 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(',')}`);
}
const measured = usage(root);
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
packages,
packageCount: packages.length,
files: measured.files,
bytes: measured.bytes,
maxFiles: MAX_FILES,
maxBytes: MAX_BYTES,
lifecycle: 'short-lived',
network: 'none-by-default',
compatible: true,
})}\n`,
);
}
try {
main();
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
@@ -0,0 +1,126 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
auditLocalOperatorImageContract,
} = require('../../scripts/ql3-local-operator-image-audit.cjs');
const root = path.resolve(__dirname, '../..');
test('accepts the short-lived Local operator image contract', () => {
const report = auditLocalOperatorImageContract(root);
assert.equal(report.compatible, true);
assert.deepEqual(report.findings, []);
assert.equal(report.lifecycle, 'short-lived');
assert.deepEqual(report.runtimePackages, [
'@qinglong/ai',
'@qinglong/local-admin',
'@qinglong/local-command-file',
'@qinglong/local-owner-cli',
'@qinglong/local-owner-console',
'@qinglong/local-secret',
'@qinglong/local-sqlite',
'@qinglong/runtime-core',
'semver',
]);
});
test('rejects a long-lived network surface or mutable runtime base', () => {
const temporaryRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-local-operator-audit-'),
);
try {
fs.mkdirSync(path.join(temporaryRoot, 'deploy/containers'), {
recursive: true,
});
fs.cpSync(
path.join(root, 'deploy/containers/ql3-local-operator'),
path.join(temporaryRoot, 'deploy/containers/ql3-local-operator'),
{ recursive: true },
);
fs.mkdirSync(path.join(temporaryRoot, '.github/workflows'), {
recursive: true,
});
fs.copyFileSync(
path.join(root, '.github/workflows/ql3-ci.yml'),
path.join(temporaryRoot, '.github/workflows/ql3-ci.yml'),
);
fs.copyFileSync(
path.join(root, 'ql3-release.json'),
path.join(temporaryRoot, 'ql3-release.json'),
);
const dockerfilePath = path.join(
temporaryRoot,
'deploy/containers/ql3-local-operator/Dockerfile',
);
const dockerfile = fs
.readFileSync(dockerfilePath, 'utf8')
.replace(
'@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436',
'',
)
.concat('\nEXPOSE 5700\n');
fs.writeFileSync(dockerfilePath, dockerfile);
const report = auditLocalOperatorImageContract(temporaryRoot);
assert.equal(report.compatible, false);
assert.ok(
report.findings.some(({ code }) => code === 'DOCKERFILE_CONTRACT_DRIFT'),
);
assert.ok(
report.findings.some(
({ code }) => code === 'UNREVIEWED_RUNTIME_OR_BUILD_SURFACE',
),
);
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
test('rejects removal of the fresh Owner journey or two-image manifest', () => {
const temporaryRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-local-operator-ci-audit-'),
);
try {
fs.mkdirSync(path.join(temporaryRoot, 'deploy/containers'), {
recursive: true,
});
fs.cpSync(
path.join(root, 'deploy/containers/ql3-local-operator'),
path.join(temporaryRoot, 'deploy/containers/ql3-local-operator'),
{ recursive: true },
);
fs.mkdirSync(path.join(temporaryRoot, '.github/workflows'), {
recursive: true,
});
const workflow = fs
.readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8')
.replaceAll(
'scripts/ql3-local-alpha-trial-kit-live-contract.cjs',
'removed-live-contract.cjs',
)
.replace(
"schema: 'qinglong/alpha-local-trial-kit@v1'",
"schema: 'single-image'",
);
fs.writeFileSync(
path.join(temporaryRoot, '.github/workflows/ql3-ci.yml'),
workflow,
);
fs.copyFileSync(
path.join(root, 'ql3-release.json'),
path.join(temporaryRoot, 'ql3-release.json'),
);
const report = auditLocalOperatorImageContract(temporaryRoot);
assert.equal(report.compatible, false);
assert.ok(
report.findings.some(
({ code }) => code === 'LOCAL_OPERATOR_CI_CONTRACT_DRIFT',
),
);
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});