diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 2efc372d..4278ec24 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -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', diff --git a/deploy/containers/ql3-local-operator/Dockerfile b/deploy/containers/ql3-local-operator/Dockerfile new file mode 100644 index 00000000..1d5d9261 --- /dev/null +++ b/deploy/containers/ql3-local-operator/Dockerfile @@ -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"] diff --git a/deploy/containers/ql3-local-operator/package-lock.json b/deploy/containers/ql3-local-operator/package-lock.json new file mode 100644 index 00000000..522cc859 --- /dev/null +++ b/deploy/containers/ql3-local-operator/package-lock.json @@ -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" + } + } +} diff --git a/deploy/containers/ql3-local-operator/package.json b/deploy/containers/ql3-local-operator/package.json new file mode 100644 index 00000000..a2c6402e --- /dev/null +++ b/deploy/containers/ql3-local-operator/package.json @@ -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" + } +} diff --git a/deploy/containers/ql3-local-operator/runtime-dependencies/package-lock.json b/deploy/containers/ql3-local-operator/runtime-dependencies/package-lock.json new file mode 100644 index 00000000..9c441e84 --- /dev/null +++ b/deploy/containers/ql3-local-operator/runtime-dependencies/package-lock.json @@ -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" + } + } + } +} diff --git a/deploy/containers/ql3-local-operator/runtime-dependencies/package.json b/deploy/containers/ql3-local-operator/runtime-dependencies/package.json new file mode 100644 index 00000000..3f1dcfe9 --- /dev/null +++ b/deploy/containers/ql3-local-operator/runtime-dependencies/package.json @@ -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" + } +} diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index ac78052b..cd855eac 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -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-.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 与双人复核仍是后续门禁。 diff --git a/docs/adr/ADR-0503-downloadable-local-alpha-trial-kit.md b/docs/adr/ADR-0503-downloadable-local-alpha-trial-kit.md new file mode 100644 index 00000000..301c4669 --- /dev/null +++ b/docs/adr/ADR-0503-downloadable-local-alpha-trial-kit.md @@ -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-.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 决策。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 74ff1a1d..4dc7397f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 双架构与实际归档待验收) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 4b5645a1..d667af97 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -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--local-amd64` 与 `ql3-alpha--local-arm64`; - `ql3-alpha--control-`、`control-ai-`、`admin-`、`worker-`。 -每个 artifact 含: +Local artifact 含: -- 通过对应测试的 native Docker archive; -- `manifest.json`,绑定版本、完整 source commit、架构、原始 image tag、image ID、archive SHA-256 与已通过 gate; +- 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-.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 或“理论上可构建”都不算用户可用产物。 diff --git a/package.json b/package.json index 8eb30098..1a87c9fb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ql3-cluster-image-sbom.cjs b/scripts/ql3-cluster-image-sbom.cjs index a66498c4..3565c7a6 100644 --- a/scripts/ql3-cluster-image-sbom.cjs +++ b/scripts/ql3-cluster-image-sbom.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; diff --git a/scripts/ql3-local-alpha-trial-kit-live-contract.cjs b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs new file mode 100644 index 00000000..45348bb2 --- /dev/null +++ b/scripts/ql3-local-alpha-trial-kit-live-contract.cjs @@ -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; +}); diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs new file mode 100644 index 00000000..89d2ea20 --- /dev/null +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -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; + } +} diff --git a/scripts/ql3-local-operator-image-inventory.cjs b/scripts/ql3-local-operator-image-inventory.cjs new file mode 100644 index 00000000..b707c184 --- /dev/null +++ b/scripts/ql3-local-operator-image-inventory.cjs @@ -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; +} diff --git a/test/back/ql3LocalOperatorImageAudit.test.cjs b/test/back/ql3LocalOperatorImageAudit.test.cjs new file mode 100644 index 00000000..97ae152c --- /dev/null +++ b/test/back/ql3LocalOperatorImageAudit.test.cjs @@ -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 }); + } +});