Files
qinglong/scripts/ql3-local-image-live-contract.cjs
T

340 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
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 {
writeSyntheticLocalReleaseSelection,
} = require('./lib/ql3-local-release-selection-test-fixture.cjs');
const MAX_OUTPUT_BYTES = 64 * 1024;
const ACTIVE_TIMEOUT_MS = 45_000;
function fail(message) {
throw new Error(`QingLong local image live contract failed: ${message}`);
}
function imageArgument(argv) {
if (argv.length < 1 || argv.length > 2 || !argv[0].startsWith('--image=')) {
fail('usage: --image=immutable-local-image [--profile=edge|standalone]');
}
const image = argv[0].slice('--image='.length);
if (
image.length < 1 ||
image.length > 512 ||
!/^[A-Za-z0-9][A-Za-z0-9._:/@-]+$/.test(image)
) {
fail('image argument is invalid');
}
return image;
}
function profileArgument(argv) {
if (argv.length === 1) return 'edge';
if (argv[1] === '--profile=edge') return 'edge';
if (argv[1] === '--profile=standalone') return 'standalone';
fail('profile argument is invalid');
}
function runDocker(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 with ${result.status}: ${(
result.stderr || result.stdout
)
.trim()
.slice(0, 2048)}`,
);
}
return result.stdout.trim();
}
function inspectImage(image) {
const output = runDocker([
'image',
'inspect',
'--format',
'{{.Id}} {{.Architecture}} {{.Config.User}}',
image,
]);
const match = /^(sha256:[0-9a-f]{64}) (amd64|arm64) (65532:65532)$/.exec(
output,
);
if (!match) fail('image identity, architecture or default user drifted');
return Object.freeze({
id: match[1],
architecture: match[2],
user: match[3],
});
}
function parseLines(buffer, events) {
let remaining = buffer;
while (remaining.includes('\n')) {
const index = remaining.indexOf('\n');
const line = remaining.slice(0, index);
remaining = remaining.slice(index + 1);
if (!line) continue;
let record;
try {
record = JSON.parse(line);
} catch {
fail('application emitted non-JSON stdout');
}
if (
!record ||
typeof record !== 'object' ||
Array.isArray(record) ||
typeof record.event !== 'string'
) {
fail('application event shape is invalid');
}
events.push(record);
}
return remaining;
}
async function runApplication(image, deploymentRoot, uid, gid, profile) {
const containerName = `ql3-local-image-${process.pid}-${crypto
.randomUUID()
.slice(0, 8)}`;
const memory = profile === 'edge' ? '128m' : '256m';
const pids = profile === 'edge' ? 64 : 256;
const child = spawn(
'docker',
[
'run',
'--rm',
'--name',
containerName,
'--read-only',
'--user',
`${uid}:${gid}`,
'--network',
'none',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--memory',
memory,
'--memory-swap',
memory,
'--cpus',
'0.5',
'--pids-limit',
String(pids),
'--tmpfs',
'/tmp:rw,nosuid,nodev,noexec,size=16m',
'--volume',
`${deploymentRoot}:/var/lib/qinglong3`,
image,
'--config',
'/var/lib/qinglong3/local-application.json',
],
{
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const events = [];
let stdout = '';
let stderr = '';
let stopped = false;
let settled = false;
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
stderr += chunk;
if (Buffer.byteLength(stderr, 'utf8') > MAX_OUTPUT_BYTES) {
child.kill('SIGKILL');
}
});
child.stdout.on('data', (chunk) => {
stdout += chunk;
if (Buffer.byteLength(stdout, 'utf8') > MAX_OUTPUT_BYTES) {
child.kill('SIGKILL');
return;
}
stdout = parseLines(stdout, events);
if (!stopped && events.some(({ event }) => event === 'active')) {
stopped = true;
runDocker(['stop', '--time', '30', containerName]);
}
});
try {
const outcome = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error('application did not converge before timeout'));
}, ACTIVE_TIMEOUT_MS);
timeout.unref();
child.once('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(error);
});
child.once('exit', (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve({ code, signal });
});
});
if (
outcome.code !== 0 ||
outcome.signal !== null ||
stdout !== '' ||
stderr !== '' ||
!stopped ||
!events.some(({ event }) => event === 'active') ||
!events.some(
({ event, signal }) =>
event === 'shutdown_requested' && signal === 'SIGTERM',
) ||
!events.some(
({ event, stopResult }) =>
event === 'stopped' && stopResult === 'stopped',
)
) {
fail(
`application lifecycle drifted: ${JSON.stringify({
code: outcome.code,
signal: outcome.signal,
stderr: stderr.slice(0, 2048),
events,
})}`,
);
}
return Object.freeze({
active: true,
gracefulStop: true,
eventCount: events.length,
});
} finally {
spawnSync('docker', ['rm', '--force', containerName], {
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 image = imageArgument(process.argv.slice(2));
const profile = profileArgument(process.argv.slice(2));
const imageIdentity = inspectImage(image);
const root = path.resolve(__dirname, '..');
const { prepareLocalDeployment } = require(path.join(
root,
'packages/ql3-local-owner-cli/dist/deployment/localDeployment.js',
));
const temporaryRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-image-live-')),
);
fs.chmodSync(temporaryRoot, 0o700);
const deploymentRoot = path.join(temporaryRoot, 'deployment');
const uid = process.getuid();
const gid = process.getgid();
const releaseSelection = writeSyntheticLocalReleaseSelection({
directory: temporaryRoot,
image: `ghcr.io/example/qinglong3-local-application@${imageIdentity.id}`,
allowRootService: uid === 0,
});
try {
const setup = await prepareLocalDeployment({
schemaVersion: 1,
operation: 'local.deployment.prepare',
options: {
deploymentRoot,
profile,
instanceId: 'local-image-live',
busyTimeoutMs: 100,
service: {
kind: 'compose',
releaseSelection,
allowRootService: uid === 0,
},
},
request: {
ownerPepperKeyId: 'owner-v1',
registerMutationId: '019f8680-143d-4000-8000-000000000001',
activateMutationId: '019f8680-143d-4000-8000-000000000002',
registeredAtMs: 1_785_254_400_000,
activatedAtMs: 1_785_254_400_001,
},
});
if (setup.status !== 'prepared' || setup.profile !== profile) {
fail('fresh deployment was not prepared');
}
const lifecycle = await runApplication(
image,
deploymentRoot,
uid,
gid,
profile,
);
const database = new DatabaseSync(
path.join(deploymentRoot, 'qinglong3.sqlite'),
{ readOnly: true },
);
let integrity;
try {
integrity = database
.prepare('PRAGMA integrity_check')
.get().integrity_check;
} finally {
database.close();
}
if (integrity !== 'ok') fail('SQLite integrity check failed');
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
imageId: imageIdentity.id,
architecture: imageIdentity.architecture,
user: imageIdentity.user,
profile,
memoryBytes: (profile === 'edge' ? 128 : 256) * 1024 * 1024,
pids: profile === 'edge' ? 64 : 256,
network: 'none',
readOnlyRoot: true,
ai: 'excluded',
lifecycle,
sqliteIntegrity: integrity,
compatible: true,
})}\n`,
);
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});