mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
747 lines
22 KiB
JavaScript
747 lines
22 KiB
JavaScript
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();
|
|
});
|