mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-07 19:19:53 +08:00
323 lines
10 KiB
JavaScript
323 lines
10 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
|
const {
|
|
getStatus,
|
|
saveState,
|
|
} = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
|
|
const { prepareBridge } = require('../../scripts/build-migration-bridge.cjs');
|
|
const baseline = require('../../docker/migration-bridge-images.json');
|
|
const tar = require('tar');
|
|
|
|
async function directory(t) {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-migration-bridge-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
return root;
|
|
}
|
|
|
|
test('new online/offline engines reject an upgrader bridge rather than downgrade the runtime', async (t) => {
|
|
const root = await directory(t),
|
|
tmp = path.join(root, '.tmp');
|
|
await fs.mkdir(tmp);
|
|
await fs.mkdir(path.join(root, 'qinglong'));
|
|
await fs.writeFile(
|
|
path.join(root, 'qinglong/bundle.json'),
|
|
JSON.stringify({ migrationBridge: { format: 1 } }),
|
|
);
|
|
const archive = path.join(root, 'bridge.tar.gz');
|
|
await tar.c({ cwd: root, file: archive, gzip: true }, ['qinglong']);
|
|
await assert.rejects(
|
|
bundle.stageBundle({
|
|
root,
|
|
tmp,
|
|
env: { QL_UPGRADE_FILE: archive },
|
|
run: async () => {
|
|
throw new Error('must not run commands');
|
|
},
|
|
}),
|
|
/download the runtime package instead/,
|
|
);
|
|
assert.deepEqual(await fs.readdir(tmp), []);
|
|
});
|
|
|
|
test('a pending second hop cannot use an old index without runtimePackages', async (t) => {
|
|
const root = await directory(t),
|
|
tmp = path.join(root, '.tmp');
|
|
await fs.mkdir(tmp);
|
|
await fs.writeFile(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({
|
|
qinglongUpgradeFormat: 2,
|
|
qinglongMigrationBridge: { format: 1 },
|
|
}),
|
|
);
|
|
const key = bundle.targetKey(await bundle.runtimeTarget());
|
|
const entry = {
|
|
url: 'https://example.com/legacy.tar.gz',
|
|
size: 10,
|
|
sha256: 'b'.repeat(64),
|
|
};
|
|
const index = {
|
|
format: 2,
|
|
sourceCommit: 'a'.repeat(40),
|
|
packages: { [key]: entry },
|
|
};
|
|
let downloaded = false;
|
|
await assert.rejects(
|
|
bundle.stageBundle({
|
|
root,
|
|
tmp,
|
|
env: { QL_UPGRADE_INDEX_URL: 'https://example.com/latest.json' },
|
|
run: async (_program, args) =>
|
|
fs.writeFile(args[args.indexOf('--output') + 1], JSON.stringify(index)),
|
|
download: async () => {
|
|
downloaded = true;
|
|
throw new Error('legacy download requested');
|
|
},
|
|
}),
|
|
/尚未提供第二步运行包/,
|
|
);
|
|
assert.equal(downloaded, false);
|
|
});
|
|
|
|
test('a runtime index cannot prepare a correctly hashed legacy database backend as the second hop', async (t) => {
|
|
const root = await directory(t),
|
|
tmp = path.join(root, '.tmp');
|
|
await fs.mkdir(tmp);
|
|
await fs.writeFile(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ qinglongUpgradeFormat: 2 }),
|
|
);
|
|
const selected = {
|
|
source: root + '/qinglong/source',
|
|
static: root + '/qinglong/static',
|
|
};
|
|
await fs.mkdir(selected.source + '/node_modules/sqlite3', {
|
|
recursive: true,
|
|
});
|
|
await fs.mkdir(selected.static, { recursive: true });
|
|
await fs.writeFile(
|
|
selected.source + '/package.json',
|
|
JSON.stringify({
|
|
dependencies: { sqlite3: 'legacy', sequelize: 'legacy' },
|
|
}),
|
|
);
|
|
await fs.writeFile(
|
|
selected.source + '/node_modules/sqlite3/package.json',
|
|
'{}',
|
|
);
|
|
await fs.writeFile(selected.source + '/pnpm-lock.yaml', 'legacy lock');
|
|
const target = await bundle.runtimeTarget(),
|
|
sourceCommit = 'a'.repeat(40);
|
|
await fs.writeFile(
|
|
root + '/qinglong/bundle.json',
|
|
JSON.stringify({
|
|
format: 2,
|
|
sourceCommit,
|
|
lockfileSha256: bundle.sha256(Buffer.from('legacy lock')),
|
|
nodeApi: 6,
|
|
nodeMinimum: '22.17.0',
|
|
target: { ...target, libcMinimum: target.libcVersion },
|
|
files: await bundle.inventory(selected),
|
|
}),
|
|
);
|
|
const archive = root + '/legacy.tar.gz';
|
|
await tar.c({ cwd: root, file: archive, gzip: true }, ['qinglong']);
|
|
const bytes = await fs.readFile(archive),
|
|
key = bundle.targetKey(target);
|
|
const index = {
|
|
format: 2,
|
|
sourceCommit,
|
|
runtimePackages: {
|
|
[key]: {
|
|
url: 'https://example.com/runtime.tar.gz',
|
|
size: bytes.length,
|
|
sha256: bundle.sha256(bytes),
|
|
},
|
|
},
|
|
};
|
|
let probed = false;
|
|
await assert.rejects(
|
|
bundle.stageBundle({
|
|
root,
|
|
tmp,
|
|
env: { QL_UPGRADE_INDEX_URL: 'https://example.com/latest.json' },
|
|
run: async (_program, args) =>
|
|
fs.writeFile(args[args.indexOf('--output') + 1], JSON.stringify(index)),
|
|
download: async (_run, file) => fs.copyFile(archive, file),
|
|
verifyStatic: async () => ({ sourceCommit }),
|
|
probe: async () => {
|
|
probed = true;
|
|
},
|
|
prestart: async () => {},
|
|
}),
|
|
/第二步更新包仍是旧数据库后端/,
|
|
);
|
|
assert.equal(probed, false);
|
|
await assert.rejects(fs.access(tmp + '/upgrade-ready-master.json'), {
|
|
code: 'ENOENT',
|
|
});
|
|
});
|
|
|
|
test('the installed marker explains partial completion and disappears without relying on stale job fields', async (t) => {
|
|
const root = await directory(t);
|
|
await saveState(root, {
|
|
status: 'completed',
|
|
phase: 'completed',
|
|
migrationRequired: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ qinglongMigrationBridge: { format: 1 } }),
|
|
);
|
|
assert.equal((await getStatus(root, { root })).migrationRequired, true);
|
|
await fs.writeFile(path.join(root, 'package.json'), '{}');
|
|
assert.equal((await getStatus(root, { root })).migrationRequired, false);
|
|
await saveState(root, { status: 'failed', error: 'prestart failed' });
|
|
assert.equal((await getStatus(root, { root })).status, 'failed');
|
|
});
|
|
|
|
for (const fault of [
|
|
'none',
|
|
'missing-platform',
|
|
'corrupt-size',
|
|
'empty-runtime',
|
|
])
|
|
test(`an upgraded client selects only runtimePackages, never the bridge (${fault})`, async (t) => {
|
|
const root = await directory(t),
|
|
tmp = path.join(root, '.tmp');
|
|
await fs.mkdir(tmp);
|
|
await fs.writeFile(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ qinglongUpgradeFormat: 2 }),
|
|
);
|
|
const key = bundle.targetKey(await bundle.runtimeTarget());
|
|
const regular = {
|
|
url: 'https://example.com/runtime.tar.gz',
|
|
size: 10,
|
|
sha256: 'b'.repeat(64),
|
|
};
|
|
const index = {
|
|
format: 2,
|
|
sourceCommit: 'a'.repeat(40),
|
|
packages: {
|
|
[key]: { ...regular, url: 'https://example.com/bridge.tar.gz' },
|
|
},
|
|
runtimePackages: { [key]: regular },
|
|
};
|
|
if (fault === 'missing-platform') delete index.runtimePackages[key];
|
|
if (fault === 'corrupt-size') regular.size = -1;
|
|
if (fault === 'empty-runtime') index.runtimePackages = null;
|
|
let downloaded = false;
|
|
await assert.rejects(
|
|
bundle.stageBundle({
|
|
root,
|
|
tmp,
|
|
env: { QL_UPGRADE_INDEX_URL: 'https://example.com/latest.json' },
|
|
run: async (_program, args) =>
|
|
fs.writeFile(
|
|
args[args.indexOf('--output') + 1],
|
|
JSON.stringify(index),
|
|
),
|
|
download: async (_run, _file, url) => {
|
|
downloaded = true;
|
|
assert.equal(url, regular.url);
|
|
throw new Error('selected runtime');
|
|
},
|
|
}),
|
|
fault === 'none' ? /selected runtime/ : /No verified update bundle/,
|
|
);
|
|
assert.equal(downloaded, fault === 'none');
|
|
assert.deepEqual(await fs.readdir(tmp), []);
|
|
});
|
|
|
|
test('bridge construction retains legacy backend, lock and field schema while replacing only updater and frontend', async (t) => {
|
|
const dir = await directory(t);
|
|
async function installation(name, commit, modern) {
|
|
const root = path.join(dir, name);
|
|
for (const sub of [
|
|
'static/build',
|
|
'static/dist',
|
|
'sample',
|
|
'cli/src/internal/maintenance',
|
|
])
|
|
await fs.mkdir(path.join(root, sub), { recursive: true });
|
|
const files = {
|
|
'build/app.js': modern ? 'new backend' : 'legacy backend',
|
|
'dist/index.html': name,
|
|
};
|
|
for (const [file, content] of Object.entries(files))
|
|
await fs.writeFile(path.join(root, 'static', file), content);
|
|
await fs.writeFile(
|
|
path.join(root, 'sample/config.sample.sh'),
|
|
'# template',
|
|
);
|
|
await fs.writeFile(path.join(root, 'pnpm-lock.yaml'), name + ' lock');
|
|
await fs.writeFile(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({
|
|
version: modern ? '2.24.0' : '2.23.0',
|
|
qinglongUpgradeFormat: 2,
|
|
dependencies: modern
|
|
? { '@mikro-orm/sql': '7.2.5' }
|
|
: { sqlite3: 'legacy', sequelize: '6' },
|
|
}),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(root, 'static/build-info.json'),
|
|
JSON.stringify({
|
|
version: 1,
|
|
dirty: false,
|
|
sourceCommit: commit,
|
|
lockfileSha256: bundle.sha256(Buffer.from(name + ' lock')),
|
|
files: Object.fromEntries(
|
|
Object.entries(files).map(([file, content]) => [
|
|
file,
|
|
bundle.sha256(Buffer.from(content)),
|
|
]),
|
|
),
|
|
}),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(root, 'cli/src/internal/maintenance/engine.cjs'),
|
|
name,
|
|
);
|
|
return root;
|
|
}
|
|
const legacy = await installation('legacy', baseline.sourceCommit, false);
|
|
const modern = await installation('modern', 'a'.repeat(40), true);
|
|
for (const file of ['.env', 'data/database.sqlite']) {
|
|
await fs.mkdir(path.dirname(path.join(legacy, file)), { recursive: true });
|
|
await fs.writeFile(path.join(legacy, file), 'synthetic must not ship');
|
|
}
|
|
const dest = path.join(dir, 'bridge');
|
|
await prepareBridge(legacy, modern, dest);
|
|
const pkg = JSON.parse(await fs.readFile(path.join(dest, 'package.json')));
|
|
assert.equal(pkg.version, '2.23.0');
|
|
assert.equal(pkg.qinglongMigrationBridge.targetVersion, '2.24.0');
|
|
assert.equal(
|
|
await fs.readFile(path.join(dest, 'static/build/app.js'), 'utf8'),
|
|
'legacy backend',
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(path.join(dest, 'pnpm-lock.yaml'), 'utf8'),
|
|
'legacy lock',
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(
|
|
path.join(dest, 'cli/src/internal/maintenance/engine.cjs'),
|
|
'utf8',
|
|
),
|
|
'modern',
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(path.join(dest, 'static/dist/index.html'), 'utf8'),
|
|
'modern',
|
|
);
|
|
await assert.rejects(fs.access(path.join(dest, '.env')), { code: 'ENOENT' });
|
|
await assert.rejects(fs.access(path.join(dest, 'data')), { code: 'ENOENT' });
|
|
});
|