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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user