mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add downloadable local alpha operator
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 archive(SHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksum;image 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 的单架构阶段产物,不是公开可下载 release;amd64 同级 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 archive(SHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksum;image 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 的单架构工程产物,不是公开可下载 release;amd64 同级 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 bytes;read-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 ID;Cluster 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` 认证 probe:old/new 在 overlap 期间均认证成功并因无 Project role 返回 403,旧代引用从 1 收敛至 0,contract 后 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/88;source `f8934b401d724378fe5a6ea9dbe63e696b5480b9` 的远程 CI run `32898407637` 为 40/40,CloudNativePG 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 TLS;material 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 PID,Standalone 使用 256 MiB、0.5 CPU、256 PID;operator 固定 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 决策。
|
||||
@@ -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 双架构与实际归档待验收) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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 identity;Admin 额外通过产品 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 为 0;128 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 tag、image 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 运行 operator;Docker 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 或“理论上可构建”都不算用户可用产物。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user