mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,646 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalPluginPackageActivationPublisher,
|
||||
} = require('@qinglong/local-admin/package-activation');
|
||||
const {
|
||||
analyzeLocalPluginPackageRecoveryCatalogPublisherKey,
|
||||
collectLocalPluginPackageRecoveryCatalog,
|
||||
createLocalPluginPackagePublisherTrustRegistry,
|
||||
inspectLocalPluginPackageRecoveryCatalog,
|
||||
publishLocalPluginPackageRecoveryCatalogEntry,
|
||||
} = require('@qinglong/local-admin/package-recovery-catalog');
|
||||
const {
|
||||
localPluginPackagePublisherKeyRevocationImpactDigest,
|
||||
publishLocalPluginPackagePublisherTrust,
|
||||
proposeLocalPluginPackagePublisherKeyRevocation,
|
||||
} = require('@qinglong/local-admin/package-publisher-trust');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
pluginPackageContentTreeDigest,
|
||||
pluginPackagePublisherSignaturePayload,
|
||||
} = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
const {
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
||||
const {
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES,
|
||||
LocalPluginPackageRecoveryCatalogError,
|
||||
createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
} = require('../dist/production-process/pluginPackageRecoveryCatalog.js');
|
||||
|
||||
const PUBLISHER = 'packages.example.com';
|
||||
const KEY_ID = 'release-2026';
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function octal(value, bytes) {
|
||||
return Buffer.from(`${value.toString(8).padStart(bytes - 1, '0')}\0`);
|
||||
}
|
||||
|
||||
function tarHeader(entryPath, bytes) {
|
||||
const header = Buffer.alloc(512);
|
||||
Buffer.from(entryPath).copy(header, 0);
|
||||
Buffer.from('0000644\0').copy(header, 100);
|
||||
Buffer.from('0000000\0').copy(header, 108);
|
||||
Buffer.from('0000000\0').copy(header, 116);
|
||||
octal(bytes, 12).copy(header, 124);
|
||||
Buffer.from('00000000000\0').copy(header, 136);
|
||||
header.fill(0x20, 148, 156);
|
||||
Buffer.from('0').copy(header, 156);
|
||||
Buffer.from('ustar\0').copy(header, 257);
|
||||
Buffer.from('00').copy(header, 263);
|
||||
const checksum = header.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${checksum.toString(8).padStart(6, '0')}\0 `).copy(header, 148);
|
||||
return header;
|
||||
}
|
||||
|
||||
function tar(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
parts.push(tarHeader(entry.path, entry.body.byteLength), entry.body);
|
||||
const padding = (512 - (entry.body.byteLength % 512)) % 512;
|
||||
if (padding > 0) parts.push(Buffer.alloc(padding));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function packageFixture(kind) {
|
||||
const packageManifest = manifest();
|
||||
const manifestBody = Buffer.from(
|
||||
serializePluginPackageManifest(packageManifest),
|
||||
);
|
||||
const taskBody = Buffer.from(
|
||||
'apiVersion: qinglong.io/v1\nkind: Task\nmetadata:\n name: collect\n',
|
||||
);
|
||||
const artifact = tar([
|
||||
{ path: 'package.json', body: manifestBody },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]);
|
||||
const artifactDigest = digest(artifact);
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(packageManifest, environment);
|
||||
const source = {
|
||||
kind,
|
||||
locator:
|
||||
kind === 'offline'
|
||||
? `offline:sha256:${artifactDigest}`
|
||||
: `oci://registry.example.com/qinglong/example-monitor@sha256:${'f'.repeat(
|
||||
64,
|
||||
)}`,
|
||||
artifactDigest,
|
||||
artifactBytes: artifact.byteLength,
|
||||
contentDigest: pluginPackageContentTreeDigest([
|
||||
{
|
||||
path: 'tasks/collect.yaml',
|
||||
bytes: taskBody.byteLength,
|
||||
digest: digest(taskBody),
|
||||
},
|
||||
]),
|
||||
};
|
||||
const action = {
|
||||
lockId: `lock-${kind}-001`,
|
||||
projectId: 'default',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: `approval-${kind}-001`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${kind}-001`,
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const key = {
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs: 100,
|
||||
notAfterMs: 10_000,
|
||||
};
|
||||
const signature = {
|
||||
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
signature: sign(
|
||||
null,
|
||||
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
|
||||
privateKey,
|
||||
).toString('base64url'),
|
||||
};
|
||||
return { artifact, key, lock, packageManifest, signature };
|
||||
}
|
||||
|
||||
function directories(t) {
|
||||
const unresolved = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-package-catalog-'),
|
||||
);
|
||||
const root = fs.realpathSync(unresolved);
|
||||
const catalogRoot = path.join(root, 'catalog');
|
||||
const bundleRoot = path.join(root, 'bundles');
|
||||
const stagingRoot = path.join(root, 'staging');
|
||||
const trustRoot = path.join(root, 'publisher-trust');
|
||||
const publisherTrustFilePath = path.join(trustRoot, 'current.json');
|
||||
fs.mkdirSync(catalogRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(bundleRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(stagingRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(trustRoot, { mode: 0o700 });
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return {
|
||||
root,
|
||||
catalogRoot,
|
||||
bundleRoot,
|
||||
stagingRoot,
|
||||
trustRoot,
|
||||
publisherTrustFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
function writePrivateJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
}
|
||||
|
||||
async function ensureTrust(filesystem, value) {
|
||||
if (fs.existsSync(filesystem.publisherTrustFilePath)) return;
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot: filesystem.trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'application-test-trust-v1',
|
||||
occurredAtMs: value.lock.createdAtMs,
|
||||
trust: {
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function publish(filesystem, value) {
|
||||
const bundlePath = path.join(
|
||||
filesystem.bundleRoot,
|
||||
`${value.lock.source.artifactDigest}.bundle`,
|
||||
);
|
||||
fs.writeFileSync(bundlePath, value.artifact, { mode: 0o600 });
|
||||
fs.chmodSync(bundlePath, 0o600);
|
||||
await ensureTrust(filesystem, value);
|
||||
const sourcePath = path.join(
|
||||
filesystem.catalogRoot,
|
||||
`${value.lock.lockDigest}.json`,
|
||||
);
|
||||
writePrivateJson(sourcePath, {
|
||||
schema: LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA,
|
||||
lockDigest: value.lock.lockDigest,
|
||||
source: value.lock.source,
|
||||
bundlePath,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
});
|
||||
return { bundlePath, sourcePath };
|
||||
}
|
||||
|
||||
function provider(filesystem) {
|
||||
return createLocalPluginPackageRecoveryCatalogStageProvider({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
publisherTrustFilePath: filesystem.publisherTrustFilePath,
|
||||
stagingRoot: filesystem.stagingRoot,
|
||||
});
|
||||
}
|
||||
|
||||
test('consumes an entry published by the authenticated catalog boundary', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
const sourceBundlePath = path.join(filesystem.root, 'incoming.bundle');
|
||||
fs.writeFileSync(sourceBundlePath, value.artifact, { mode: 0o600 });
|
||||
fs.chmodSync(sourceBundlePath, 0o600);
|
||||
await ensureTrust(filesystem, value);
|
||||
|
||||
let publicationGuards = 0;
|
||||
const published = await publishLocalPluginPackageRecoveryCatalogEntry({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
sourceBundlePath,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: createLocalPluginPackagePublisherTrustRegistry({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
}),
|
||||
confirmPublicationAllowed() {
|
||||
publicationGuards += 1;
|
||||
assert.equal(
|
||||
fs
|
||||
.readdirSync(filesystem.catalogRoot)
|
||||
.filter((entry) => /^\.qlpkg-catalog-[0-9a-f]{32}\.tmp$/.test(entry))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(published.status, 'published');
|
||||
assert.equal(publicationGuards, 2);
|
||||
assert.equal(published.lockDigest, value.lock.lockDigest);
|
||||
assert.equal(
|
||||
fs.statSync(
|
||||
path.join(
|
||||
filesystem.bundleRoot,
|
||||
`${value.lock.source.artifactDigest}.bundle`,
|
||||
),
|
||||
).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
const staged = await provider(filesystem).stage(value.lock);
|
||||
assert.equal(staged.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(staged.manifestDigest, value.lock.manifestDigest);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await publishLocalPluginPackageRecoveryCatalogEntry({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
sourceBundlePath,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: createLocalPluginPackagePublisherTrustRegistry({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
}),
|
||||
})
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
inspectLocalPluginPackageRecoveryCatalog({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
}),
|
||||
{
|
||||
lockDigests: [value.lock.lockDigest],
|
||||
entryCount: 1,
|
||||
bundleCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
analyzeLocalPluginPackageRecoveryCatalogPublisherKey({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
}),
|
||||
{
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
publishLocalPluginPackageRecoveryCatalogEntry({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
sourceBundlePath,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: createLocalPluginPackagePublisherTrustRegistry({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: [value.key],
|
||||
}),
|
||||
confirmPublicationAllowed() {
|
||||
throw new Error('retirement intent won');
|
||||
},
|
||||
}),
|
||||
/catalog publication is unavailable/,
|
||||
);
|
||||
assert.equal(
|
||||
fs
|
||||
.readdirSync(filesystem.catalogRoot)
|
||||
.some((entry) => entry.endsWith('.tmp')),
|
||||
false,
|
||||
);
|
||||
|
||||
const catalogTransaction = path.join(
|
||||
filesystem.catalogRoot,
|
||||
`.qlpkg-catalog-${'a'.repeat(32)}.tmp`,
|
||||
);
|
||||
const bundleTransaction = path.join(
|
||||
filesystem.bundleRoot,
|
||||
`.qlpkg-bundle-${'b'.repeat(32)}.tmp`,
|
||||
);
|
||||
fs.writeFileSync(catalogTransaction, '', { mode: 0o600 });
|
||||
fs.writeFileSync(bundleTransaction, '', { mode: 0o600 });
|
||||
let deleteFences = 0;
|
||||
const collected = await collectLocalPluginPackageRecoveryCatalog({
|
||||
catalogRoot: filesystem.catalogRoot,
|
||||
bundleRoot: filesystem.bundleRoot,
|
||||
candidateLockDigests: [value.lock.lockDigest],
|
||||
maxDeletes: 4,
|
||||
beforeDelete() {
|
||||
deleteFences += 1;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(collected, {
|
||||
removedEntries: 1,
|
||||
removedBundles: 1,
|
||||
removedTransactions: 2,
|
||||
remaining: false,
|
||||
});
|
||||
assert.equal(deleteFences, 1);
|
||||
});
|
||||
|
||||
test('verifies a historical lock at its immutable creation time', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, {
|
||||
...value,
|
||||
key: {
|
||||
...value.key,
|
||||
notAfterMs: value.lock.createdAtMs + 1,
|
||||
},
|
||||
});
|
||||
|
||||
const staged = await provider(filesystem).stage(value.lock);
|
||||
assert.equal(staged.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(staged.manifestDigest, value.lock.manifestDigest);
|
||||
});
|
||||
|
||||
test('blocks queued staging as soon as a compromise proposal is durable', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, value);
|
||||
const impactedLockDigests = [value.lock.lockDigest];
|
||||
const impact = {
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
impactedLockDigests,
|
||||
impactDigest: localPluginPackagePublisherKeyRevocationImpactDigest({
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
impactedLockDigests,
|
||||
}),
|
||||
};
|
||||
await proposeLocalPluginPackagePublisherKeyRevocation({
|
||||
trustRoot: filesystem.trustRoot,
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'application-test-revoke-v2',
|
||||
occurredAtMs: 300,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
proposerSubjectId: 'owner-a',
|
||||
impact,
|
||||
});
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
for (const kind of ['offline', 'oci']) {
|
||||
test(`stages one exact ${kind} lock from the materialized catalog`, async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture(kind);
|
||||
const files = await publish(filesystem, value);
|
||||
const stageProvider = provider(filesystem);
|
||||
const staged = await stageProvider.stage(value.lock);
|
||||
assert.equal(staged.stageRef, `local-stage:${value.lock.lockDigest}`);
|
||||
assert.equal(staged.artifactDigest, value.lock.source.artifactDigest);
|
||||
assert.equal(staged.manifestDigest, value.lock.manifestDigest);
|
||||
assert.equal(staged.contentDigest, value.lock.source.contentDigest);
|
||||
|
||||
fs.unlinkSync(files.bundlePath);
|
||||
assert.deepEqual(await stageProvider.stage(value.lock), staged);
|
||||
});
|
||||
}
|
||||
|
||||
test('recovers one durable queued install to active through the catalog', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const activationRoot = path.join(filesystem.root, 'activation');
|
||||
fs.mkdirSync(activationRoot, { mode: 0o700 });
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, value);
|
||||
const database = new DatabaseSync(':memory:');
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(database);
|
||||
t.after(() => database.close());
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(database);
|
||||
const queued = createPluginPackageInstall(value.lock, {
|
||||
installationId: 'catalog-recovery-install-001',
|
||||
mutationId: 'catalog-recovery-create-001',
|
||||
occurredAtMs: 300,
|
||||
});
|
||||
await repository.create(pluginPackageInstallCreate(value.lock, queued, null));
|
||||
const recovery = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: provider(filesystem),
|
||||
publisher: new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: filesystem.stagingRoot,
|
||||
activationRoot,
|
||||
now: () => 700,
|
||||
}),
|
||||
now: () => 600,
|
||||
});
|
||||
|
||||
const result = await recovery.recover({ pageSize: 1, maxPages: 2 });
|
||||
|
||||
assert.equal(result.safeToAdmit, true);
|
||||
assert.equal(result.settled, 1);
|
||||
const active = await repository.find(
|
||||
value.lock.projectId,
|
||||
value.lock.packageName,
|
||||
);
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, value.lock.lockDigest);
|
||||
});
|
||||
|
||||
test('construction is lazy and a missing locked entry fails closed', async (t) => {
|
||||
const root = path.join(directoryName(t), 'not-created');
|
||||
const stageProvider = createLocalPluginPackageRecoveryCatalogStageProvider({
|
||||
catalogRoot: root,
|
||||
bundleRoot: path.join(root, 'bundles'),
|
||||
publisherTrustFilePath: path.join(root, 'trust', 'current.json'),
|
||||
stagingRoot: path.join(root, 'staging'),
|
||||
});
|
||||
await assert.rejects(
|
||||
stageProvider.stage(packageFixture('offline').lock),
|
||||
LocalPluginPackageRecoveryCatalogError,
|
||||
);
|
||||
});
|
||||
|
||||
function directoryName(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-catalog-lazy-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return fs.realpathSync(root);
|
||||
}
|
||||
|
||||
test('rejects source drift, widened trust and unknown catalog entries', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
const files = await publish(filesystem, value);
|
||||
const entry = JSON.parse(fs.readFileSync(files.sourcePath, 'utf8'));
|
||||
entry.source.artifactDigest = 'f'.repeat(64);
|
||||
writePrivateJson(files.sourcePath, entry);
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/does not match its durable PackageLock/,
|
||||
);
|
||||
|
||||
await publish(filesystem, value);
|
||||
fs.chmodSync(filesystem.publisherTrustFilePath, 0o644);
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/trust file must be a bounded owner-only regular file/,
|
||||
);
|
||||
|
||||
fs.chmodSync(filesystem.publisherTrustFilePath, 0o600);
|
||||
fs.writeFileSync(path.join(filesystem.catalogRoot, 'unexpected'), '', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
});
|
||||
|
||||
test('hard-caps catalog cardinality before reading a source', async (t) => {
|
||||
const filesystem = directories(t);
|
||||
const value = packageFixture('offline');
|
||||
await publish(filesystem, value);
|
||||
for (
|
||||
let index = 0;
|
||||
index < MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES;
|
||||
index += 1
|
||||
) {
|
||||
const name = index.toString(16).padStart(64, '0');
|
||||
if (name === value.lock.lockDigest) continue;
|
||||
writePrivateJson(path.join(filesystem.catalogRoot, `${name}.json`), {});
|
||||
}
|
||||
const existing = fs.readdirSync(filesystem.catalogRoot).length;
|
||||
if (existing <= MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES) {
|
||||
writePrivateJson(
|
||||
path.join(filesystem.catalogRoot, `${'e'.repeat(64)}.json`),
|
||||
{},
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readdirSync(filesystem.catalogRoot).length >
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES,
|
||||
true,
|
||||
);
|
||||
await assert.rejects(
|
||||
provider(filesystem).stage(value.lock),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes catalog authority only through its explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/local-application/plugin-package-recovery-catalog');
|
||||
assert.equal(
|
||||
root.createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
subpath.createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
createLocalPluginPackageRecoveryCatalogStageProvider,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
inspectLegacySqlitePath,
|
||||
prepareLocalSqliteActivation,
|
||||
stageLocalSqliteAdoption,
|
||||
} = require('@qinglong/local-admin');
|
||||
const { provisionLocalSecretKeyring } = require('@qinglong/local-secret');
|
||||
const {
|
||||
LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA,
|
||||
LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2,
|
||||
LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3,
|
||||
LocalApplicationProcessConfigError,
|
||||
loadLocalApplicationProcessConfig,
|
||||
} = require('../dist/production-process/processConfig.js');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalApplicationProcessError,
|
||||
runProductionLocalApplicationProcess,
|
||||
} = require('../dist/production-process/processApplication.js');
|
||||
const {
|
||||
localApplicationStartupReceiptPath,
|
||||
parseLocalApplicationStartupReceipt,
|
||||
} = require('../dist/production-process/startupReceipt.js');
|
||||
const {
|
||||
localApplicationShutdownReceiptPath,
|
||||
parseLocalApplicationShutdownReceipt,
|
||||
} = require('../dist/production-process/shutdownReceipt.js');
|
||||
|
||||
function directory(t, prefix = 'ql3-local-process-') {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
t.after(() => fs.rmSync(value, { recursive: true, force: true }));
|
||||
return value;
|
||||
}
|
||||
|
||||
function configValue(root, overrides = {}) {
|
||||
const value = {
|
||||
schema: LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3,
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
storage: {
|
||||
mode: 'adopted',
|
||||
sourcePath: path.join(root, 'database.sqlite'),
|
||||
targetPath: path.join(root, 'qinglong3.sqlite'),
|
||||
recoveryPath: path.join(root, 'database.pre-ql3.sqlite'),
|
||||
manifestPath: path.join(root, 'qinglong3-adoption.json'),
|
||||
activationPath: path.join(root, 'qinglong3-activation.json'),
|
||||
expectedActivationDigest: 'a'.repeat(64),
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
runtime: {
|
||||
receiptRoot: path.join(root, 'receipts'),
|
||||
artifactRoot: path.join(root, 'artifacts'),
|
||||
secretKeyringPath: path.join(root, 'secret-keyring.json'),
|
||||
},
|
||||
pluginPackages: {
|
||||
stagingRoot: path.join(root, 'plugin-staging'),
|
||||
activationRoot: path.join(root, 'plugin-activation'),
|
||||
recoverySource: { mode: 'disabled' },
|
||||
pageSize: 4,
|
||||
maxPages: 4,
|
||||
taskPublicationPageSize: 4,
|
||||
taskPublicationMaxPages: 4,
|
||||
},
|
||||
ai: { deployment: 'excluded' },
|
||||
...overrides,
|
||||
};
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-legacy-silence-commitment',
|
||||
state: 'legacy_stopped',
|
||||
cutoverId: 'cutover-test-1',
|
||||
profile: value.profile,
|
||||
instanceId: value.instanceId,
|
||||
activationDigest: value.storage.expectedActivationDigest,
|
||||
previousRecordDigest: 'b'.repeat(64),
|
||||
requestedAtMs: 1_000,
|
||||
observedAtMs: 1_000,
|
||||
controller: {
|
||||
kind: 'docker',
|
||||
endpointDigest: 'c'.repeat(64),
|
||||
legacyContainerId: 'd'.repeat(64),
|
||||
legacyContainerIdentityDigest: 'e'.repeat(64),
|
||||
legacySourceBindingDigest: 'f'.repeat(64),
|
||||
},
|
||||
};
|
||||
const commitmentDigest = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex');
|
||||
return {
|
||||
...value,
|
||||
cutover: {
|
||||
cutoverId: payload.cutoverId,
|
||||
commitmentPath: path.join(root, 'legacy-stopped.json'),
|
||||
expectedCommitmentDigest: commitmentDigest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeCutoverCommitment(value) {
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-legacy-silence-commitment',
|
||||
state: 'legacy_stopped',
|
||||
cutoverId: value.cutover.cutoverId,
|
||||
profile: value.profile,
|
||||
instanceId: value.instanceId,
|
||||
activationDigest: value.storage.expectedActivationDigest,
|
||||
previousRecordDigest: 'b'.repeat(64),
|
||||
requestedAtMs: 1_000,
|
||||
observedAtMs: 1_000,
|
||||
controller: {
|
||||
kind: 'docker',
|
||||
endpointDigest: 'c'.repeat(64),
|
||||
legacyContainerId: 'd'.repeat(64),
|
||||
legacyContainerIdentityDigest: 'e'.repeat(64),
|
||||
legacySourceBindingDigest: 'f'.repeat(64),
|
||||
},
|
||||
};
|
||||
const commitment = {
|
||||
...payload,
|
||||
commitmentDigest: crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(payload), 'utf8')
|
||||
.digest('hex'),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
value.cutover.commitmentPath,
|
||||
`${JSON.stringify(commitment)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return commitment.commitmentDigest;
|
||||
}
|
||||
|
||||
function writeConfig(t, value = configValue(directory(t))) {
|
||||
const configFilePath = path.join(
|
||||
path.dirname(value.storage.sourcePath),
|
||||
'local-application.json',
|
||||
);
|
||||
if (value.schema === LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V3) {
|
||||
value.cutover.expectedCommitmentDigest = writeCutoverCommitment(value);
|
||||
}
|
||||
fs.writeFileSync(configFilePath, `${JSON.stringify(value)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.chmodSync(configFilePath, 0o600);
|
||||
return { configFilePath, value };
|
||||
}
|
||||
|
||||
function freshConfigValue(root) {
|
||||
return {
|
||||
schema: LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA_V2,
|
||||
instanceId: 'fresh-edge-router-1',
|
||||
profile: 'edge',
|
||||
storage: {
|
||||
mode: 'fresh',
|
||||
databasePath: path.join(root, 'qinglong3.sqlite'),
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
runtime: {
|
||||
receiptRoot: path.join(root, 'receipts'),
|
||||
artifactRoot: path.join(root, 'artifacts'),
|
||||
secretKeyringPath: path.join(root, 'secret-keyring.json'),
|
||||
},
|
||||
pluginPackages: {
|
||||
stagingRoot: path.join(root, 'plugin-staging'),
|
||||
activationRoot: path.join(root, 'plugin-activation'),
|
||||
recoverySource: { mode: 'disabled' },
|
||||
pageSize: 4,
|
||||
maxPages: 4,
|
||||
taskPublicationPageSize: 4,
|
||||
taskPublicationMaxPages: 4,
|
||||
},
|
||||
ai: { deployment: 'excluded' },
|
||||
};
|
||||
}
|
||||
|
||||
function writeFreshConfig(root, value = freshConfigValue(root)) {
|
||||
const configFilePath = path.join(root, 'local-application-fresh.json');
|
||||
fs.writeFileSync(configFilePath, `${JSON.stringify(value)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.chmodSync(configFilePath, 0o600);
|
||||
return { configFilePath, value };
|
||||
}
|
||||
|
||||
test('loads one exact private process configuration', (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath, value } = writeConfig(t, configValue(root));
|
||||
assert.deepEqual(loadLocalApplicationProcessConfig(configFilePath), value);
|
||||
|
||||
fs.chmodSync(configFilePath, 0o644);
|
||||
assert.throws(
|
||||
() => loadLocalApplicationProcessConfig(configFilePath),
|
||||
/private regular file/,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires an exact v3 commitment before adopted startup authority', async (t) => {
|
||||
const root = directory(t, 'ql3-local-cutover-config-');
|
||||
const v3 = configValue(root);
|
||||
const { cutover: _cutover, ...legacy } = v3;
|
||||
const v1 = {
|
||||
...legacy,
|
||||
schema: LOCAL_APPLICATION_PROCESS_CONFIG_SCHEMA,
|
||||
storage: Object.fromEntries(
|
||||
Object.entries(legacy.storage).filter(([key]) => key !== 'mode'),
|
||||
),
|
||||
};
|
||||
const legacyConfig = writeConfig(t, v1);
|
||||
let subscribed = false;
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath: legacyConfig.configFilePath,
|
||||
signals: {
|
||||
subscribe() {
|
||||
subscribed = true;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start() {
|
||||
throw new Error('must not start');
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'QL3_LOCAL_APPLICATION_CUTOVER_COMMITMENT_INVALID',
|
||||
);
|
||||
assert.equal(subscribed, false);
|
||||
|
||||
const ready = writeConfig(t, v3);
|
||||
const commitment = JSON.parse(
|
||||
fs.readFileSync(v3.cutover.commitmentPath, 'utf8'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
v3.cutover.commitmentPath,
|
||||
`${JSON.stringify({ ...commitment, instanceId: 'other-instance' })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath: ready.configFilePath,
|
||||
signals: { subscribe: () => () => {} },
|
||||
emit() {},
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'QL3_LOCAL_APPLICATION_CUTOVER_COMMITMENT_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
test('loads an exact v2 fresh storage configuration', (t) => {
|
||||
const root = directory(t, 'ql3-local-fresh-config-');
|
||||
const { configFilePath, value } = writeFreshConfig(root);
|
||||
assert.deepEqual(loadLocalApplicationProcessConfig(configFilePath), value);
|
||||
|
||||
const widened = {
|
||||
...value,
|
||||
storage: { ...value.storage, sourcePath: path.join(root, 'legacy.sqlite') },
|
||||
};
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(widened), { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => loadLocalApplicationProcessConfig(configFilePath),
|
||||
LocalApplicationProcessConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
test('boots a migrated fresh database without an adoption fence', async (t) => {
|
||||
const root = directory(t, 'ql3-local-fresh-live-');
|
||||
const { configFilePath, value } = writeFreshConfig(root);
|
||||
await migrateLocalSqlitePath({
|
||||
databasePath: value.storage.databasePath,
|
||||
profile: value.profile,
|
||||
busyTimeoutMs: value.storage.busyTimeoutMs,
|
||||
});
|
||||
await provisionLocalSecretKeyring(value.runtime.secretKeyringPath);
|
||||
fs.mkdirSync(value.pluginPackages.stagingRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
fs.mkdirSync(value.pluginPackages.activationRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
let listener;
|
||||
const result = await runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe(receive) {
|
||||
listener = receive;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit(record) {
|
||||
events.push(record);
|
||||
if (record.event === 'active') setImmediate(() => listener('SIGTERM'));
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(
|
||||
events.some(({ event }) => event === 'active'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
events.some(
|
||||
({ dependencyActivation }) => dependencyActivation?.scope === 'adoption',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const database = new DatabaseSync(value.storage.databasePath, {
|
||||
readonly: true,
|
||||
});
|
||||
assert.equal(
|
||||
database.prepare('PRAGMA integrity_check').get().integrity_check,
|
||||
'ok',
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('rejects widened, relative, aliased and unbounded process authority', (t) => {
|
||||
const root = directory(t);
|
||||
const cases = [
|
||||
{
|
||||
...configValue(root),
|
||||
unexpected: true,
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
storage: {
|
||||
...configValue(root).storage,
|
||||
sourcePath: 'database.sqlite',
|
||||
},
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
runtime: {
|
||||
...configValue(root).runtime,
|
||||
artifactRoot: path.join(root, 'receipts'),
|
||||
},
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
ai: { deployment: 'installed', maxConcurrent: 65 },
|
||||
},
|
||||
{
|
||||
...configValue(root),
|
||||
pluginPackages: {
|
||||
...configValue(root).pluginPackages,
|
||||
recoverySource: {
|
||||
mode: 'materialized_catalog',
|
||||
catalogRoot: path.join(root, 'plugin-staging'),
|
||||
bundleRoot: path.join(root, 'plugin-bundles'),
|
||||
publisherTrustFilePath: path.join(
|
||||
root,
|
||||
'publisher-trust',
|
||||
'current.json',
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const [index, value] of cases.entries()) {
|
||||
const configFilePath = path.join(root, `invalid-${index}.json`);
|
||||
fs.writeFileSync(configFilePath, JSON.stringify(value), { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => loadLocalApplicationProcessConfig(configFilePath),
|
||||
LocalApplicationProcessConfigError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('subscribes before startup, accepts the first signal and drains once', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath, value } = writeConfig(t, configValue(root));
|
||||
const actions = [];
|
||||
const events = [];
|
||||
let listener;
|
||||
let stops = 0;
|
||||
const result = await runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe(receive) {
|
||||
actions.push('subscribe');
|
||||
listener = receive;
|
||||
return () => actions.push('unsubscribe');
|
||||
},
|
||||
},
|
||||
emit(record) {
|
||||
events.push(record);
|
||||
},
|
||||
async start(options) {
|
||||
actions.push('start');
|
||||
assert.equal('create' in options.application, false);
|
||||
assert.equal(options.application.profile, value.profile);
|
||||
assert.equal(
|
||||
typeof options.application.pluginPackages.stageProvider.stage,
|
||||
'function',
|
||||
);
|
||||
await options.application.applicationAudit({
|
||||
profile: value.profile,
|
||||
state: 'active',
|
||||
});
|
||||
listener('SIGTERM');
|
||||
listener('SIGINT');
|
||||
return {
|
||||
status: 'active',
|
||||
profile: value.profile,
|
||||
application: {},
|
||||
ai: { status: 'deployment_excluded' },
|
||||
async stop() {
|
||||
stops += 1;
|
||||
actions.push('stop');
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(stops, 1);
|
||||
assert.deepEqual(actions, ['subscribe', 'start', 'stop', 'unsubscribe']);
|
||||
assert.deepEqual(
|
||||
events.map(({ event }) => event),
|
||||
['application_activation', 'active', 'shutdown_requested', 'stopped'],
|
||||
);
|
||||
assert.equal(events[2].signal, 'SIGTERM');
|
||||
const serialized = JSON.stringify(events);
|
||||
assert.equal(serialized.includes(root), false);
|
||||
assert.equal(
|
||||
serialized.includes(value.storage.expectedActivationDigest),
|
||||
false,
|
||||
);
|
||||
if (process.platform === 'linux') {
|
||||
const receipt = parseLocalApplicationStartupReceipt(
|
||||
fs.readFileSync(
|
||||
localApplicationStartupReceiptPath(configFilePath),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(receipt.instanceId, value.instanceId);
|
||||
assert.equal(receipt.profile, value.profile);
|
||||
assert.equal(receipt.aiStatus, 'deployment_excluded');
|
||||
assert.equal(receipt.processId, process.pid);
|
||||
}
|
||||
});
|
||||
|
||||
test('installed AI fails before storage startup without provider authority', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath } = writeConfig(
|
||||
t,
|
||||
configValue(root, { ai: { deployment: 'installed' } }),
|
||||
);
|
||||
let subscribed = false;
|
||||
let started = false;
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe() {
|
||||
subscribed = true;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start() {
|
||||
started = true;
|
||||
throw new Error('must not start');
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof LocalApplicationProcessError &&
|
||||
error.code === 'QL3_LOCAL_APPLICATION_PROCESS_AI_PROVIDER_UNAVAILABLE',
|
||||
);
|
||||
assert.equal(subscribed, false);
|
||||
assert.equal(started, false);
|
||||
});
|
||||
|
||||
test('default Plugin Package recovery source fails closed and unsubscribes', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath } = writeConfig(t, configValue(root));
|
||||
let unsubscribed = false;
|
||||
await assert.rejects(
|
||||
runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe() {
|
||||
return () => {
|
||||
unsubscribed = true;
|
||||
};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start(options) {
|
||||
await options.application.pluginPackages.stageProvider.stage({});
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof LocalApplicationProcessError &&
|
||||
error.code === 'QL3_LOCAL_APPLICATION_PLUGIN_SOURCE_UNAVAILABLE',
|
||||
);
|
||||
assert.equal(unsubscribed, true);
|
||||
});
|
||||
|
||||
test('materialized catalog stays unloaded when recovery has no queued source', async (t) => {
|
||||
const root = directory(t);
|
||||
const { configFilePath } = writeConfig(
|
||||
t,
|
||||
configValue(root, {
|
||||
pluginPackages: {
|
||||
...configValue(root).pluginPackages,
|
||||
recoverySource: {
|
||||
mode: 'materialized_catalog',
|
||||
catalogRoot: path.join(root, 'plugin-catalog'),
|
||||
bundleRoot: path.join(root, 'plugin-bundles'),
|
||||
publisherTrustFilePath: path.join(
|
||||
root,
|
||||
'publisher-trust',
|
||||
'current.json',
|
||||
),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const catalogModule = require.resolve(
|
||||
'../dist/production-process/pluginPackageRecoveryCatalog.js',
|
||||
);
|
||||
assert.equal(require.cache[catalogModule], undefined);
|
||||
let listener;
|
||||
const result = await runProductionLocalApplicationProcess({
|
||||
configFilePath,
|
||||
signals: {
|
||||
subscribe(receive) {
|
||||
listener = receive;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
emit() {},
|
||||
async start(options) {
|
||||
assert.equal(
|
||||
typeof options.application.pluginPackages.stageProvider.stage,
|
||||
'function',
|
||||
);
|
||||
listener('SIGTERM');
|
||||
return {
|
||||
status: 'active',
|
||||
profile: 'edge',
|
||||
application: {},
|
||||
ai: { status: 'deployment_excluded' },
|
||||
async stop() {
|
||||
return 'stopped';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(result, 'stopped');
|
||||
assert.equal(require.cache[catalogModule], undefined);
|
||||
});
|
||||
|
||||
test('CLI exposes bounded usage and redacted configuration failures', (t) => {
|
||||
const cli = path.resolve(__dirname, '../dist/cli.js');
|
||||
const help = spawnSync(process.execPath, [cli, '--help'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0, help.stderr);
|
||||
assert.equal(
|
||||
help.stdout,
|
||||
'Usage: ql3-local-application --config /absolute/private-config.json\n',
|
||||
);
|
||||
|
||||
const usage = spawnSync(process.execPath, [cli], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 64);
|
||||
assert.equal(
|
||||
JSON.parse(usage.stderr).code,
|
||||
'QL3_LOCAL_APPLICATION_CLI_USAGE_INVALID',
|
||||
);
|
||||
|
||||
const root = directory(t, 'ql3-local-cli-failure-');
|
||||
const { configFilePath } = writeConfig(
|
||||
t,
|
||||
configValue(root, { ai: { deployment: 'installed' } }),
|
||||
);
|
||||
const failed = spawnSync(
|
||||
process.execPath,
|
||||
[cli, '--config', configFilePath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(failed.status, 1, failed.stdout);
|
||||
assert.equal(
|
||||
JSON.parse(failed.stderr).code,
|
||||
'QL3_LOCAL_APPLICATION_PROCESS_AI_PROVIDER_UNAVAILABLE',
|
||||
);
|
||||
assert.equal(failed.stderr.includes(root), false);
|
||||
});
|
||||
|
||||
async function prepareCliFixture(t) {
|
||||
const root = directory(t, 'ql3-local-cli-live-');
|
||||
const value = configValue(root);
|
||||
const source = new DatabaseSync(value.storage.sourcePath);
|
||||
source.exec(`
|
||||
CREATE TABLE "Auths" (id INTEGER PRIMARY KEY, type TEXT, info TEXT);
|
||||
CREATE TABLE "Crontabs" (
|
||||
id INTEGER PRIMARY KEY, command TEXT NOT NULL, schedule TEXT
|
||||
);
|
||||
CREATE TABLE "Envs" (
|
||||
id INTEGER PRIMARY KEY, name TEXT, value TEXT
|
||||
);
|
||||
INSERT INTO "Crontabs" (id, command, schedule)
|
||||
VALUES (1, 'echo legacy', '0 0 * * *');
|
||||
`);
|
||||
source.close();
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: value.storage.sourcePath,
|
||||
profile: value.profile,
|
||||
});
|
||||
const adoption = await stageLocalSqliteAdoption({
|
||||
sourcePath: value.storage.sourcePath,
|
||||
targetPath: value.storage.targetPath,
|
||||
recoveryPath: value.storage.recoveryPath,
|
||||
manifestPath: value.storage.manifestPath,
|
||||
profile: value.profile,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
});
|
||||
const activation = await prepareLocalSqliteActivation({
|
||||
sourcePath: value.storage.sourcePath,
|
||||
targetPath: value.storage.targetPath,
|
||||
recoveryPath: value.storage.recoveryPath,
|
||||
manifestPath: value.storage.manifestPath,
|
||||
activationPath: value.storage.activationPath,
|
||||
expectedManifestDigest: adoption.manifestDigest,
|
||||
});
|
||||
await provisionLocalSecretKeyring(value.runtime.secretKeyringPath);
|
||||
fs.mkdirSync(value.pluginPackages.stagingRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
fs.mkdirSync(value.pluginPackages.activationRoot, {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
const ready = {
|
||||
...value,
|
||||
storage: {
|
||||
...value.storage,
|
||||
expectedActivationDigest: activation.activationDigest,
|
||||
},
|
||||
};
|
||||
return { root, ...writeConfig(t, ready) };
|
||||
}
|
||||
|
||||
test('CLI boots the real headless runtime and releases it on SIGTERM', async (t) => {
|
||||
const { configFilePath, value } = await prepareCliFixture(t);
|
||||
const cli = path.resolve(__dirname, '../dist/cli.js');
|
||||
const child = spawn(process.execPath, [cli, '--config', configFilePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const events = [];
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let signalled = false;
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
while (stdout.includes('\n')) {
|
||||
const index = stdout.indexOf('\n');
|
||||
const line = stdout.slice(0, index);
|
||||
stdout = stdout.slice(index + 1);
|
||||
if (!line) continue;
|
||||
const record = JSON.parse(line);
|
||||
events.push(record);
|
||||
if (record.event === 'active' && !signalled) {
|
||||
signalled = true;
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
});
|
||||
const timeout = setTimeout(() => child.kill('SIGKILL'), 15_000);
|
||||
timeout.unref();
|
||||
const [code, signal] = await new Promise((resolve) => {
|
||||
child.once('exit', (...args) => resolve(args));
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify({ stderr, signal, events }));
|
||||
assert.equal(signal, null);
|
||||
assert.equal(signalled, true);
|
||||
assert.equal(
|
||||
events.some(({ event }) => event === 'active'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
events.some(
|
||||
({ event, signal: observed }) =>
|
||||
event === 'shutdown_requested' && observed === 'SIGTERM',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
events.some(
|
||||
({ event, stopResult }) =>
|
||||
event === 'stopped' && stopResult === 'stopped',
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (process.platform === 'linux') {
|
||||
const receipt = parseLocalApplicationStartupReceipt(
|
||||
fs.readFileSync(
|
||||
localApplicationStartupReceiptPath(configFilePath),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(receipt.processId, child.pid);
|
||||
assert.equal(receipt.aiStatus, 'deployment_excluded');
|
||||
const shutdown = parseLocalApplicationShutdownReceipt(
|
||||
fs.readFileSync(
|
||||
localApplicationShutdownReceiptPath(configFilePath),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(shutdown.processId, child.pid);
|
||||
assert.equal(shutdown.processStartTicks, receipt.processStartTicks);
|
||||
assert.equal(shutdown.bootId, receipt.bootId);
|
||||
assert.equal(shutdown.signal, 'SIGTERM');
|
||||
assert.equal(shutdown.stopResult, 'stopped');
|
||||
assert.equal(shutdown.startupReceiptDigest, receipt.sha256);
|
||||
assert.ok(shutdown.stoppedBootAgeMs >= receipt.activeBootAgeMs);
|
||||
}
|
||||
const writer = new DatabaseSync(value.storage.sourcePath, { timeout: 100 });
|
||||
writer
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo released');
|
||||
writer.close();
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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 {
|
||||
LocalApplicationShutdownReceiptError,
|
||||
buildLocalApplicationShutdownReceipt,
|
||||
localApplicationShutdownReceiptPath,
|
||||
observeLocalApplicationShutdown,
|
||||
parseLocalApplicationShutdownReceipt,
|
||||
publishLocalApplicationShutdownReceipt,
|
||||
} = require('../dist/production-process/shutdownReceipt.js');
|
||||
|
||||
function directory(t) {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-stop-receipt-'));
|
||||
t.after(() => fs.rmSync(value, { recursive: true, force: true }));
|
||||
return value;
|
||||
}
|
||||
|
||||
function receipt(stoppedBootAgeMs = 2_500, processId = 41) {
|
||||
return buildLocalApplicationShutdownReceipt({
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
signal: 'SIGTERM',
|
||||
startupReceiptDigest: 'a'.repeat(64),
|
||||
observation: {
|
||||
bootId: '12345678-1234-4abc-8def-123456789abc',
|
||||
stoppedBootAgeMs,
|
||||
processId,
|
||||
processStartTicks: String(100 + processId),
|
||||
nodeExecutable: '/usr/bin/node',
|
||||
nodeVersion: 'v24.18.0',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('publishes one bounded graceful shutdown receipt', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const target = publishLocalApplicationShutdownReceipt(
|
||||
configFilePath,
|
||||
receipt(),
|
||||
);
|
||||
assert.equal(target, localApplicationShutdownReceiptPath(configFilePath));
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(target).nlink, 1);
|
||||
assert.equal(fs.existsSync(`${target}.stage`), false);
|
||||
const parsed = parseLocalApplicationShutdownReceipt(
|
||||
fs.readFileSync(target, 'utf8'),
|
||||
);
|
||||
assert.equal(parsed.signal, 'SIGTERM');
|
||||
assert.equal(parsed.stopResult, 'stopped');
|
||||
assert.equal(parsed.startupReceiptDigest, 'a'.repeat(64));
|
||||
});
|
||||
|
||||
test('atomically replaces the prior process shutdown receipt', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const target = publishLocalApplicationShutdownReceipt(
|
||||
configFilePath,
|
||||
receipt(),
|
||||
);
|
||||
const replacement = receipt(3_000, 42);
|
||||
assert.equal(
|
||||
publishLocalApplicationShutdownReceipt(configFilePath, replacement),
|
||||
target,
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationShutdownReceipt(fs.readFileSync(target, 'utf8')),
|
||||
replacement,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonicalizes the Linux runtime observation property order', () => {
|
||||
const runtimeOrderedObservation = {
|
||||
bootId: '12345678-1234-4abc-8def-123456789abc',
|
||||
processId: 41,
|
||||
processStartTicks: '141',
|
||||
nodeExecutable: '/usr/bin/node',
|
||||
nodeVersion: 'v24.18.0',
|
||||
stoppedBootAgeMs: 2_500,
|
||||
};
|
||||
const built = buildLocalApplicationShutdownReceipt({
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
signal: 'SIGTERM',
|
||||
startupReceiptDigest: 'a'.repeat(64),
|
||||
observation: runtimeOrderedObservation,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationShutdownReceipt(JSON.stringify(built)),
|
||||
built,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a forged digest and unsafe deterministic stage', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const valid = receipt();
|
||||
assert.throws(
|
||||
() =>
|
||||
parseLocalApplicationShutdownReceipt(
|
||||
JSON.stringify({
|
||||
...valid,
|
||||
stoppedBootAgeMs: valid.stoppedBootAgeMs + 1,
|
||||
}),
|
||||
),
|
||||
/digest is invalid/,
|
||||
);
|
||||
const target = localApplicationShutdownReceiptPath(configFilePath);
|
||||
const outside = path.join(root, 'outside');
|
||||
fs.writeFileSync(outside, 'do-not-replace', { mode: 0o600 });
|
||||
fs.symlinkSync(outside, `${target}.stage`);
|
||||
assert.throws(
|
||||
() => publishLocalApplicationShutdownReceipt(configFilePath, valid),
|
||||
LocalApplicationShutdownReceiptError,
|
||||
);
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'do-not-replace');
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
test(
|
||||
'observes the still-live Linux process after application shutdown',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
() => {
|
||||
const observed = observeLocalApplicationShutdown();
|
||||
assert.equal(observed.processId, process.pid);
|
||||
assert.match(observed.bootId, /^[0-9a-f-]{36}$/);
|
||||
assert.ok(observed.stoppedBootAgeMs >= 0);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
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 {
|
||||
LocalApplicationStartupReceiptError,
|
||||
buildLocalApplicationStartupReceipt,
|
||||
localApplicationStartupReceiptPath,
|
||||
observeLocalApplicationStartup,
|
||||
parseLinuxProcessStartTicks,
|
||||
parseLocalApplicationStartupReceipt,
|
||||
publishLocalApplicationStartupReceipt,
|
||||
} = require('../dist/production-process/startupReceipt.js');
|
||||
|
||||
function directory(t) {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-startup-receipt-'));
|
||||
t.after(() => fs.rmSync(value, { recursive: true, force: true }));
|
||||
return value;
|
||||
}
|
||||
|
||||
function receipt(activeBootAgeMs = 1_250, processId = 41) {
|
||||
return buildLocalApplicationStartupReceipt({
|
||||
instanceId: 'edge-router-1',
|
||||
profile: 'edge',
|
||||
aiStatus: 'deployment_excluded',
|
||||
observation: {
|
||||
bootId: '12345678-1234-4abc-8def-123456789abc',
|
||||
activeBootAgeMs,
|
||||
processId,
|
||||
processStartTicks: String(100 + processId),
|
||||
nodeExecutable: '/usr/bin/node',
|
||||
nodeVersion: 'v24.18.0',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('parses Linux stat after the final command delimiter', () => {
|
||||
const fields = [
|
||||
'S',
|
||||
...Array.from({ length: 18 }, (_, index) => String(index + 1)),
|
||||
'987654',
|
||||
'21',
|
||||
];
|
||||
assert.equal(
|
||||
parseLinuxProcessStartTicks(`41 (node worker) name) ${fields.join(' ')}`),
|
||||
'987654',
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLinuxProcessStartTicks('41 invalid'),
|
||||
LocalApplicationStartupReceiptError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes one bounded current receipt with atomic replacement', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const first = receipt();
|
||||
const target = publishLocalApplicationStartupReceipt(configFilePath, first);
|
||||
assert.equal(target, localApplicationStartupReceiptPath(configFilePath));
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
|
||||
assert.equal(fs.existsSync(`${target}.stage`), false);
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationStartupReceipt(fs.readFileSync(target, 'utf8')),
|
||||
first,
|
||||
);
|
||||
|
||||
const second = receipt(1_500, 42);
|
||||
assert.equal(
|
||||
publishLocalApplicationStartupReceipt(configFilePath, second),
|
||||
target,
|
||||
);
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(target).nlink, 1);
|
||||
assert.deepEqual(
|
||||
parseLocalApplicationStartupReceipt(fs.readFileSync(target, 'utf8')),
|
||||
second,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a forged digest and an unsafe deterministic stage', (t) => {
|
||||
const root = directory(t);
|
||||
const configFilePath = path.join(root, 'local-application.json');
|
||||
const target = localApplicationStartupReceiptPath(configFilePath);
|
||||
const valid = receipt();
|
||||
const forged = { ...valid, activeBootAgeMs: valid.activeBootAgeMs + 1 };
|
||||
assert.throws(
|
||||
() => parseLocalApplicationStartupReceipt(JSON.stringify(forged)),
|
||||
/digest is invalid/,
|
||||
);
|
||||
|
||||
const outside = path.join(root, 'outside');
|
||||
fs.writeFileSync(outside, 'do-not-replace', { mode: 0o600 });
|
||||
fs.symlinkSync(outside, `${target}.stage`);
|
||||
assert.throws(
|
||||
() => publishLocalApplicationStartupReceipt(configFilePath, valid),
|
||||
/existing receipt stage is not a private regular file/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'do-not-replace');
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
test(
|
||||
'observes the live Linux boot and direct Node process when available',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
() => {
|
||||
const observed = observeLocalApplicationStartup();
|
||||
assert.equal(observed.processId, process.pid);
|
||||
assert.match(observed.bootId, /^[0-9a-f-]{36}$/);
|
||||
assert.match(observed.processStartTicks, /^[1-9][0-9]+$/);
|
||||
assert.equal(path.isAbsolute(observed.nodeExecutable), true);
|
||||
assert.equal(observed.nodeVersion, process.version);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user