mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
358 lines
12 KiB
JavaScript
358 lines
12 KiB
JavaScript
#!/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 RUNTIME_OS_PATCH =
|
|
'RUN apk add --no-cache --upgrade \\\n' +
|
|
' docker-cli=29.5.2-r0 \\\n' +
|
|
' libcrypto3=3.5.8-r0 \\\n' +
|
|
' libssl3=3.5.8-r0';
|
|
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`,
|
|
RUNTIME_OS_PATCH,
|
|
'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) ||
|
|
(contents.match(/\bapk\b/gu) || []).length !== 1
|
|
) {
|
|
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',
|
|
'--entrypoint /usr/bin/docker \\\n "${OPERATOR_IMAGE}" --version',
|
|
'scripts/ql3-local-alpha-trial-kit-live-contract.cjs',
|
|
'scripts/ql3-local-alpha-trial-kit-bundle.cjs',
|
|
'--mode=record-verification',
|
|
'--mode=create',
|
|
'--mode=audit',
|
|
'sh "${BUNDLE_ROOT}/quickstart.sh" \\\n edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}"',
|
|
'docker stop --time 30 "${QUICKSTART_CONTAINER}"',
|
|
'test -s "${QUICKSTART_ROOT}/qinglong3.sqlite"',
|
|
'sh "${BUNDLE_ROOT}/upgrade-cutover-rehearsal.sh"',
|
|
'"status":"rollback_candidate"',
|
|
'--capture-after-write "${RECONCILIATION_CAPTURE_ROOT}"',
|
|
'"status":"reconciliation_captured"',
|
|
'"rollback":"not_authorized"',
|
|
'reconciliation-capture-verify.result.json',
|
|
'scripts/ql3-local-alpha-reconciliation-decision-fixture.cjs',
|
|
'sh "${BUNDLE_ROOT}/reconciliation-rehearsal.sh"',
|
|
'RECONCILIATION_REVIEW_DECISION_ROOT=',
|
|
'RECONCILIATION_AUTOMATION_DECISION_ROOT=',
|
|
'"status":"operator_decision_required"',
|
|
'"automaticDecision":"not_authorized"',
|
|
'--mode=review',
|
|
'"status":"automation_decision_required"',
|
|
'"decisionAuthority":"authenticated_user"',
|
|
'"automaticRowDecision":"not_authorized"',
|
|
'--mode=automation',
|
|
'apply-rollback edge',
|
|
'"${LEGACY_READINESS_ROOT}"',
|
|
'"status":"reconciliation_automation_rolled_back"',
|
|
'automation-apply-verify.result.json',
|
|
'automation-rollback-verify.result.json',
|
|
'"completion":"not_attempted"',
|
|
'--shape=completion-ready',
|
|
'--mode=completion-review',
|
|
'apply-plan edge',
|
|
'"status":"secret_config_decision_required"',
|
|
'"automaticCandidateDecision":"not_authorized"',
|
|
'--mode=secret-config',
|
|
'complete edge',
|
|
'"status":"reconciliation_completed"',
|
|
'"adapterCount":3',
|
|
'completion-verify.result.json',
|
|
'"targetRestart":"not_authorized"',
|
|
'"legacyRestart":"not_authorized"',
|
|
'docker rm "${TARGET_CONTAINER}" "${LEGACY_CONTAINER}"',
|
|
'--application-sbom="${APPLICATION_SBOM}"',
|
|
'--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"',
|
|
'--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"',
|
|
'--readme=docs/operations/ql3-local-alpha-trial-kit.md',
|
|
'--repository=${{ github.repository }}',
|
|
'--workflow-ref="${{ github.workflow_ref }}"',
|
|
'--workflow-sha=${{ github.workflow_sha }}',
|
|
'--event=${{ github.event_name }}',
|
|
'--job=${{ github.job }}',
|
|
'--run-id=${{ github.run_id }}',
|
|
'--run-attempt=${{ github.run_attempt }}',
|
|
'--variant="${TRIAL_VARIANT}"',
|
|
'inputs.local_alpha_variant',
|
|
];
|
|
for (const value of required) {
|
|
if (!contents.includes(value))
|
|
finding(findings, 'LOCAL_OPERATOR_CI_CONTRACT_DRIFT', value);
|
|
}
|
|
let cursor = -1;
|
|
for (const value of [
|
|
'name: Run the downloadable Local Alpha trial kit journey',
|
|
'name: Run authenticated Local API cancellation through real Linux processes',
|
|
'--mode=record-verification',
|
|
'--mode=create',
|
|
'--mode=audit',
|
|
'/quickstart.sh"',
|
|
'/upgrade-cutover-rehearsal.sh"',
|
|
'--capture-after-write',
|
|
'/reconciliation-rehearsal.sh"',
|
|
'--mode=review',
|
|
'--mode=automation',
|
|
'apply-rollback edge',
|
|
'--shape=completion-ready',
|
|
'--mode=completion-review',
|
|
'apply-plan edge',
|
|
'--mode=secret-config',
|
|
'complete edge',
|
|
'name: Upload the tested native Local Alpha trial kit',
|
|
]) {
|
|
const index = contents.indexOf(value, cursor + 1);
|
|
if (index <= cursor) {
|
|
finding(findings, 'LOCAL_OPERATOR_CI_GATE_ORDER_DRIFT', value);
|
|
break;
|
|
}
|
|
cursor = index;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|