mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user