mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:21:50 +08:00
220 lines
7.1 KiB
JavaScript
220 lines
7.1 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const { test } = require('node:test');
|
|
const {
|
|
inspectLegacySqlitePath,
|
|
prepareLocalSqliteActivation,
|
|
stageLocalSqliteAdoption,
|
|
} = require('@qinglong/local-admin');
|
|
const {
|
|
bootstrapLocalAdoptedProfileStorage,
|
|
} = require('@qinglong/local-admin/adopted-profile');
|
|
|
|
function fixture(t) {
|
|
const directory = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ql3-adopted-profile-'),
|
|
);
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const value = {
|
|
directory,
|
|
sourcePath: path.join(directory, 'database.sqlite'),
|
|
targetPath: path.join(directory, 'qinglong3.sqlite'),
|
|
recoveryPath: path.join(directory, 'database.pre-ql3.sqlite'),
|
|
manifestPath: path.join(directory, 'qinglong3-adoption.json'),
|
|
activationPath: path.join(directory, 'qinglong3-activation.json'),
|
|
};
|
|
const source = new DatabaseSync(value.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();
|
|
return value;
|
|
}
|
|
|
|
async function prepare(t, profile = 'edge') {
|
|
const value = fixture(t);
|
|
const plan = inspectLegacySqlitePath({
|
|
sourcePath: value.sourcePath,
|
|
profile,
|
|
});
|
|
const adoption = await stageLocalSqliteAdoption({
|
|
...value,
|
|
profile,
|
|
expectedPlanDigest: plan.planDigest,
|
|
});
|
|
const activation = await prepareLocalSqliteActivation({
|
|
...value,
|
|
expectedManifestDigest: adoption.manifestDigest,
|
|
});
|
|
return { ...value, activation };
|
|
}
|
|
|
|
test('runtime adopted composition does not load executable migration SQL', () => {
|
|
const script = `
|
|
require(${JSON.stringify(path.resolve(__dirname, '../dist/adopted-profile/localAdoptedProfile.js'))});
|
|
const loaded = Object.keys(require.cache)
|
|
.filter((entry) => /[\\/]local-sqlite[\\/]dist[\\/](?:migration|migrations[\\/])/.test(entry));
|
|
process.stdout.write(JSON.stringify(loaded));
|
|
`;
|
|
const result = spawnSync(process.execPath, ['-e', script], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.deepEqual(JSON.parse(result.stdout), []);
|
|
});
|
|
|
|
test('disabled adopted composition never inspects activation paths', async () => {
|
|
const storageAudits = [];
|
|
const adoptionAudits = [];
|
|
const result = await bootstrapLocalAdoptedProfileStorage({
|
|
enabled: false,
|
|
profile: 'edge',
|
|
sourcePath: 'invalid',
|
|
targetPath: 'invalid',
|
|
recoveryPath: 'invalid',
|
|
manifestPath: 'invalid',
|
|
activationPath: 'invalid',
|
|
expectedActivationDigest: 'invalid',
|
|
audit: (record) => storageAudits.push(record),
|
|
adoptionAudit: (record) => adoptionAudits.push(record),
|
|
});
|
|
|
|
assert.equal(result.status, 'disabled');
|
|
assert.deepEqual(
|
|
storageAudits.map(({ state }) => state),
|
|
['disabled'],
|
|
);
|
|
assert.deepEqual(
|
|
adoptionAudits.map(({ state }) => state),
|
|
['disabled'],
|
|
);
|
|
assert.equal(await result.stop(), 'stopped');
|
|
assert.deepEqual(
|
|
adoptionAudits.map(({ state }) => state),
|
|
['disabled', 'stopped'],
|
|
);
|
|
});
|
|
|
|
test('starts target storage only while the legacy source remains fenced', async (t) => {
|
|
const value = await prepare(t, 'edge');
|
|
const storageAudits = [];
|
|
const adoptionAudits = [];
|
|
const result = await bootstrapLocalAdoptedProfileStorage({
|
|
enabled: true,
|
|
profile: 'edge',
|
|
...value,
|
|
expectedActivationDigest: value.activation.activationDigest,
|
|
busyTimeoutMs: 100,
|
|
audit: (record) => storageAudits.push(record),
|
|
adoptionAudit: (record) => adoptionAudits.push(record),
|
|
});
|
|
|
|
assert.equal(result.status, 'adopted_storage_ready');
|
|
assert.equal(result.evidence.contractName, 'local-control-core');
|
|
assert.deepEqual(await result.startupRecovery.inspectCandidates(), {
|
|
candidates: [],
|
|
truncated: false,
|
|
});
|
|
assert.deepEqual(
|
|
adoptionAudits.map(({ state }) => state),
|
|
['fence_acquired', 'storage_ready'],
|
|
);
|
|
const legacyWriter = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
|
assert.throws(
|
|
() =>
|
|
legacyWriter
|
|
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
|
.run(2, 'echo blocked'),
|
|
(error) => error && error.errstr === 'database is locked',
|
|
);
|
|
|
|
assert.equal(await result.stop(), 'stopped');
|
|
assert.equal(await result.stop(), 'stopped');
|
|
legacyWriter
|
|
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
|
.run(2, 'echo released');
|
|
legacyWriter.close();
|
|
assert.deepEqual(
|
|
adoptionAudits.map(({ state }) => state),
|
|
['fence_acquired', 'storage_ready', 'stopped'],
|
|
);
|
|
assert.ok(storageAudits.some(({ state }) => state === 'storage_ready'));
|
|
assert.ok(storageAudits.some(({ state }) => state === 'stopped'));
|
|
});
|
|
|
|
test('source drift fails activation and releases the temporary fence', async (t) => {
|
|
const value = await prepare(t, 'standalone');
|
|
const source = new DatabaseSync(value.sourcePath);
|
|
source
|
|
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
|
.run(2, 'echo late');
|
|
source.close();
|
|
const adoptionAudits = [];
|
|
|
|
await assert.rejects(
|
|
bootstrapLocalAdoptedProfileStorage({
|
|
enabled: true,
|
|
profile: 'standalone',
|
|
...value,
|
|
expectedActivationDigest: value.activation.activationDigest,
|
|
busyTimeoutMs: 100,
|
|
audit() {},
|
|
adoptionAudit: (record) => adoptionAudits.push(record),
|
|
}),
|
|
/legacy source identity or catalog changed after staging/,
|
|
);
|
|
assert.deepEqual(
|
|
adoptionAudits.map(({ state }) => state),
|
|
['failed'],
|
|
);
|
|
const writer = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
|
writer
|
|
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
|
.run(3, 'echo no leaked fence');
|
|
writer.close();
|
|
});
|
|
|
|
test('fails before readiness when the target path changes while storage opens', async (t) => {
|
|
const value = await prepare(t, 'edge');
|
|
const replacementPath = path.join(value.directory, 'replacement.sqlite');
|
|
const adoptionAudits = [];
|
|
|
|
await assert.rejects(
|
|
bootstrapLocalAdoptedProfileStorage({
|
|
enabled: true,
|
|
profile: 'edge',
|
|
...value,
|
|
expectedActivationDigest: value.activation.activationDigest,
|
|
busyTimeoutMs: 100,
|
|
audit(record) {
|
|
if (record.state !== 'storage_ready') return;
|
|
fs.copyFileSync(value.targetPath, replacementPath);
|
|
fs.renameSync(replacementPath, value.targetPath);
|
|
},
|
|
adoptionAudit: (record) => adoptionAudits.push(record),
|
|
}),
|
|
/target database identity does not match the activation/,
|
|
);
|
|
assert.deepEqual(
|
|
adoptionAudits.map(({ state }) => state),
|
|
['fence_acquired', 'failed'],
|
|
);
|
|
const writer = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
|
writer
|
|
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
|
.run(2, 'echo no leaked fence');
|
|
writer.close();
|
|
});
|