mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
597 lines
17 KiB
JavaScript
597 lines
17 KiB
JavaScript
#!/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|variant)=(.+)$/u.exec(
|
|
argument,
|
|
);
|
|
if (!match || Object.hasOwn(values, match[1]))
|
|
fail('arguments are invalid');
|
|
values[match[1]] = match[2];
|
|
}
|
|
if (
|
|
Object.keys(values).length !== 4 ||
|
|
!IMAGE_PATTERN.test(values['application-image'] ?? '') ||
|
|
!IMAGE_PATTERN.test(values['operator-image'] ?? '') ||
|
|
!['edge', 'standalone'].includes(values.profile) ||
|
|
!['headless', 'console'].includes(values.variant)
|
|
) {
|
|
fail(
|
|
'usage: --application-image=... --operator-image=... --profile=edge|standalone --variant=headless|console',
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
applicationImage: values['application-image'],
|
|
operatorImage: values['operator-image'],
|
|
profile: values.profile,
|
|
variant: values.variant,
|
|
});
|
|
}
|
|
|
|
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, variant) {
|
|
const application = JSON.parse(
|
|
docker(['image', 'inspect', applicationImage]),
|
|
)[0];
|
|
const operator = JSON.parse(docker(['image', 'inspect', operatorImage]))[0];
|
|
const applicationLabels = application?.Config?.Labels;
|
|
const operatorLabels = operator?.Config?.Labels;
|
|
const expectedTitle =
|
|
variant === 'console'
|
|
? 'QingLong 3.0 Local Console Application'
|
|
: 'QingLong 3.0 Local Application';
|
|
const expectedProfile =
|
|
variant === 'console'
|
|
? 'edge-application-api,standalone-application-api'
|
|
: 'edge,standalone';
|
|
if (
|
|
!/^sha256:[0-9a-f]{64}$/u.test(application?.Id ?? '') ||
|
|
!/^sha256:[0-9a-f]{64}$/u.test(operator?.Id ?? '') ||
|
|
application?.Architecture !== operator?.Architecture ||
|
|
!['amd64', 'arm64'].includes(application?.Architecture) ||
|
|
application?.Config?.User !== '65532:65532' ||
|
|
operator?.Config?.User !== '65532:65532' ||
|
|
applicationLabels?.['org.opencontainers.image.title'] !== expectedTitle ||
|
|
applicationLabels?.['io.qinglong.profile'] !== expectedProfile ||
|
|
applicationLabels?.['io.qinglong.ai'] !== 'excluded' ||
|
|
(variant === 'console'
|
|
? applicationLabels?.['io.qinglong.local.console'] !==
|
|
'offline-loopback'
|
|
: applicationLabels?.['io.qinglong.local.console'] !== undefined) ||
|
|
operatorLabels?.['io.qinglong.lifecycle'] !== 'short-lived' ||
|
|
operatorLabels?.['io.qinglong.authority'] !== 'local-owner-management' ||
|
|
!/^[0-9a-f]{40}$/u.test(
|
|
applicationLabels?.['org.opencontainers.image.revision'] ?? '',
|
|
) ||
|
|
applicationLabels?.['org.opencontainers.image.revision'] !==
|
|
operatorLabels?.['org.opencontainers.image.revision'] ||
|
|
applicationLabels?.['org.opencontainers.image.version'] !==
|
|
operatorLabels?.['org.opencontainers.image.version'] ||
|
|
!/^3\.0\.0-alpha\.[0-9]+$/u.test(
|
|
applicationLabels?.['org.opencontainers.image.version'] ?? '',
|
|
)
|
|
) {
|
|
fail('image identity, architecture or authority labels drifted');
|
|
}
|
|
return Object.freeze({
|
|
architecture: application.Architecture,
|
|
applicationId: application.Id,
|
|
operatorId: operator.Id,
|
|
});
|
|
}
|
|
|
|
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' },
|
|
});
|
|
if (state.variant === 'console') {
|
|
writePrivateJson(path.join(state.root, 'local-api.json'), {
|
|
schema: 'qinglong/local-api-process@v1',
|
|
deploymentRoot: '/var/lib/qinglong3',
|
|
applicationConfigFilePath:
|
|
'/var/lib/qinglong3/local-application.json',
|
|
ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers',
|
|
listener: { host: '127.0.0.1', port: 5700 },
|
|
});
|
|
}
|
|
}
|
|
|
|
function delay(milliseconds) {
|
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
async function consoleSurfaceContract() {
|
|
let lastError;
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
try {
|
|
const root = await fetch('http://127.0.0.1:5700/', {
|
|
redirect: 'manual',
|
|
signal: AbortSignal.timeout(2_000),
|
|
});
|
|
const api = await fetch(
|
|
'http://127.0.0.1:5700/api/v3/projects/default/tasks',
|
|
{
|
|
redirect: 'manual',
|
|
signal: AbortSignal.timeout(2_000),
|
|
},
|
|
);
|
|
await root.body?.cancel();
|
|
await api.body?.cancel();
|
|
if (root.status !== 200 || api.status !== 401) {
|
|
fail(
|
|
`Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`,
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
listener: '127.0.0.1:5700',
|
|
rootStatus: 200,
|
|
unauthenticatedApiStatus: 401,
|
|
});
|
|
} catch (error) {
|
|
lastError = error;
|
|
await delay(250);
|
|
}
|
|
}
|
|
throw lastError || new Error('Console listener did not become ready');
|
|
}
|
|
|
|
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',
|
|
state.variant === 'console' ? 'host' : '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',
|
|
state.variant === 'console'
|
|
? '/var/lib/qinglong3/local-api.json'
|
|
: '/var/lib/qinglong3/local-application.json',
|
|
],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let active = false;
|
|
let surfaceError;
|
|
let surface = Object.freeze({ status: 'not_applicable' });
|
|
let surfacePromise = Promise.resolve();
|
|
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;
|
|
surfacePromise = (async () => {
|
|
try {
|
|
if (state.variant === 'console') {
|
|
surface = await consoleSurfaceContract();
|
|
}
|
|
} catch (error) {
|
|
surfaceError = error;
|
|
} finally {
|
|
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 });
|
|
});
|
|
});
|
|
await surfacePromise;
|
|
if (surfaceError) throw surfaceError;
|
|
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, surface });
|
|
} 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,
|
|
options.variant,
|
|
);
|
|
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: 2,
|
|
schema: 'qinglong/local-alpha-trial-kit-live@v2',
|
|
variant: options.variant,
|
|
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;
|
|
});
|