Files
qinglong/test/back/migration-bridge.test.cjs

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' });
});