mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-09 18:24:55 +08:00
fix: rebuild legacy migration SQLite for ARMv6 on the builder
This commit is contained in:
@@ -10,6 +10,7 @@ USER root
|
||||
COPY --from=legacy /ql/ /legacy/
|
||||
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
|
||||
export QEMU_CPU=arm1176; \
|
||||
apk add --no-cache build-base nodejs-dev && \
|
||||
node --jitless /ql/scripts/build-migration-bridge.cjs /legacy /ql /out && \
|
||||
for archive in /out/qinglong-*-linux-armv6-musl.tar.gz; do \
|
||||
case "$archive" in */qinglong-migration-*) continue ;; esac; \
|
||||
|
||||
@@ -8,6 +8,8 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
|
||||
|
||||
兼容包单独命名为 `qinglong-migration-<commit>-<platform>.tar.gz`,来自固定摘要、固定源码提交的旧镜像,保留真实旧 SQLite/Sequelize 依赖和旧数据库后端,只替换升级器、前端和构建身份;它不伪造 SQLite 模块,也不执行用户数据迁移。正常运行包仍为 `qinglong-<commit>-<platform>.tar.gz`,只使用 node:sqlite。索引的 `packages` 为旧客户端保留兼容包,`runtimePackages` 指向正式运行包;新版升级器优先且只使用完整的 `runtimePackages`,不因损坏或缺失而退回兼容包。新版在线/离线入口会拒绝误用兼容包。生产索引发布前,每个平台都必须使用未经修改的旧升级器真实下载、校验、预启动第一步,再使用包内新版升级器下载、校验、预启动第二步。两步失败均沿用原有回滚流程。
|
||||
|
||||
固定旧镜像中的 generic ARM SQLite addon 实际使用 ARMv7 指令。ARMv6 musl 兼容包会在 CI 构建时从同一旧依赖源码重新编译该 addon,使用匹配构建 Node 的本地头文件、单并发及 ARMv6/VFP 硬浮点参数,并检查 ELF 指令集,拒绝 ARMv7、Thumb-2 或 VFPv3 产物。旧锁文件及后端保持不变,其他架构不执行此步骤;正常运行包仍不包含旧 SQLite,目标设备无需为第一步迁移联网编译。
|
||||
|
||||
旧源码归档升级器继续保留原兼容路径;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。第一步旧后端的检查仍使用真实旧依赖,第二步才切换到 MikroORM 和 Node 内置 SQLite。
|
||||
|
||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → MikroORM/cron-parser/gRPC/undici 加载及 node:sqlite 内存库读写(旧包继续检查 SQLite/Sequelize) → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||
|
||||
@@ -13,6 +13,7 @@ const { buildBundle } = require('./build-upgrade-bundle.cjs');
|
||||
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const baseline = require('../docker/migration-bridge-images.json');
|
||||
const { rebuildMigrationSqlite } = require('./rebuild-migration-sqlite.cjs');
|
||||
const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
|
||||
|
||||
async function prepareBridge(legacy, current, destination) {
|
||||
@@ -236,6 +237,7 @@ async function buildMigrationBridge(legacy, current, output) {
|
||||
try {
|
||||
const root = path.join(workspace, 'root');
|
||||
await prepareBridge(legacy, current, root);
|
||||
await rebuildMigrationSqlite(root);
|
||||
const runtime = await buildBundle(current, output);
|
||||
bridge = await buildBundle(root, output);
|
||||
const verification = await verifyBothDownloads(legacy, current, output, [
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// The legacy image's generic ARM prebuild contains ARMv7 instructions. Rebuild
|
||||
// only the first-hop bridge on the builder; never add sqlite3 to normal bundles
|
||||
// or ask an ARMv6 device to compile it during the migration.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
|
||||
function assertArmv6Addon(attributes) {
|
||||
if (
|
||||
!/Tag_CPU_arch:\s+v6\s*$/m.test(attributes) ||
|
||||
/Tag_THUMB_ISA_use:\s+Thumb-2|Tag_FP_arch:\s+VFPv[3-9]/.test(attributes) ||
|
||||
!/Tag_ABI_VFP_args:\s+VFP registers/.test(attributes)
|
||||
)
|
||||
throw new Error('Legacy migration SQLite must use ARMv6 hard-float instructions');
|
||||
}
|
||||
|
||||
async function rebuildMigrationSqlite(root, {
|
||||
target,
|
||||
run = promisify(execFile),
|
||||
runtime = process,
|
||||
} = {}) {
|
||||
target ||= await bundle.runtimeTarget();
|
||||
if (target.arch !== 'armv6' || target.libc !== 'musl') return false;
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json')));
|
||||
if (!pkg.qinglongMigrationBridge || !pkg.dependencies?.sqlite3)
|
||||
throw new Error('Only a legacy migration bridge may rebuild SQLite');
|
||||
const r = createRequire(path.join(root, 'package.json'));
|
||||
const sqlite = path.dirname(r.resolve('sqlite3/package.json'));
|
||||
const fromSqlite = createRequire(path.join(sqlite, 'package.json'));
|
||||
const preGyp = fromSqlite.resolve('@mapbox/node-pre-gyp/bin/node-pre-gyp');
|
||||
const nodeGyp = fromSqlite.resolve('node-gyp/bin/node-gyp.js');
|
||||
const prefix = path.resolve(path.dirname(runtime.execPath), '..');
|
||||
const header = await fs.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8');
|
||||
const version = ['MAJOR', 'MINOR', 'PATCH'].map((part) =>
|
||||
header.match(new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`))?.[1],
|
||||
).join('.');
|
||||
if (version !== runtime.versions.node)
|
||||
throw new Error('Migration SQLite requires headers matching the existing Node');
|
||||
const lock = await fs.readFile(path.join(root, 'pnpm-lock.yaml'));
|
||||
await run(runtime.execPath, [
|
||||
'--jitless', preGyp, 'rebuild', '--build-from-source',
|
||||
'--napi_build_version=6', `--nodedir=${prefix}`, '--jobs=1',
|
||||
'--arm_version=6', '--arm_fpu=vfp', '--arm_float_abi=hard',
|
||||
], {
|
||||
cwd: sqlite,
|
||||
timeout: 1800000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
env: {
|
||||
PATH: runtime.env.PATH,
|
||||
NODE_PATH: '',
|
||||
NODE_OPTIONS: '--jitless --max-old-space-size=256',
|
||||
npm_config_node_gyp: nodeGyp,
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_force_process_config: 'true',
|
||||
npm_config_nodedir: prefix,
|
||||
npm_config_jobs: '1',
|
||||
MAKEFLAGS: '-j1',
|
||||
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||
},
|
||||
});
|
||||
const addon = path.join(sqlite, 'lib/binding/napi-v6-linux-arm/node_sqlite3.node');
|
||||
const { stdout } = await run('readelf', ['-A', addon], { timeout: 30000 });
|
||||
assertArmv6Addon(stdout);
|
||||
if (!lock.equals(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))))
|
||||
throw new Error('Migration SQLite rebuild changed the frozen lockfile');
|
||||
console.log('Legacy bridge SQLite rebuilt and verified for ARMv6 hard-float');
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { rebuildMigrationSqlite, assertArmv6Addon };
|
||||
@@ -0,0 +1,73 @@
|
||||
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 { rebuildMigrationSqlite, assertArmv6Addon } = require('../../scripts/rebuild-migration-sqlite.cjs');
|
||||
|
||||
const armv6 = 'Tag_CPU_arch: v6\nTag_THUMB_ISA_use: Thumb-1\nTag_FP_arch: VFPv2\nTag_ABI_VFP_args: VFP registers\n';
|
||||
const target = { arch: 'armv6', libc: 'musl' };
|
||||
|
||||
test('rejects the actual legacy ARMv7/Thumb-2/VFPv3 prebuild on ARM1176', () => {
|
||||
assert.throws(() => assertArmv6Addon('Tag_CPU_arch: v7\nTag_THUMB_ISA_use: Thumb-2\nTag_FP_arch: VFPv3-D16\nTag_ABI_VFP_args: VFP registers\n'), /ARMv6 hard-float/);
|
||||
for (const attributes of ['', armv6.replace('VFPv2', 'VFPv3'), armv6.replace('Thumb-1', 'Thumb-2'), armv6.replace('VFP registers', 'Base AAPCS')])
|
||||
assert.throws(() => assertArmv6Addon(attributes), /ARMv6 hard-float/);
|
||||
assert.doesNotThrow(() => assertArmv6Addon(armv6));
|
||||
});
|
||||
|
||||
test('does not inspect or modify any other architecture, libc or Node version', async () => {
|
||||
for (const arch of ['x64', 'armv7', 'arm64', 'ia32', 's390x', 'ppc64'])
|
||||
for (const node of ['20.20.2', '22.17.0', '24.18.1'])
|
||||
assert.equal(await rebuildMigrationSqlite('/must-not-exist', { target: { arch, libc: 'musl' }, runtime: { versions: { node } }, run: () => { throw new Error('unexpected command'); } }), false);
|
||||
assert.equal(await rebuildMigrationSqlite('/must-not-exist', { target: { arch: 'armv6', libc: 'glibc' } }), false);
|
||||
});
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bridge-armv6-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
for (const name of ['node_modules/sqlite3', 'node_modules/@mapbox/node-pre-gyp/bin', 'node_modules/node-gyp/bin', 'runtime/include/node', 'runtime/bin'])
|
||||
await fs.mkdir(path.join(root, name), { recursive: true });
|
||||
await fs.writeFile(root + '/package.json', JSON.stringify({ qinglongMigrationBridge: { format: 1 }, dependencies: { sqlite3: 'legacy' } }));
|
||||
await fs.writeFile(root + '/pnpm-lock.yaml', 'fixed legacy lock');
|
||||
await fs.writeFile(root + '/node_modules/sqlite3/package.json', '{}');
|
||||
await fs.writeFile(root + '/node_modules/@mapbox/node-pre-gyp/bin/node-pre-gyp', '');
|
||||
await fs.writeFile(root + '/node_modules/node-gyp/bin/node-gyp.js', '');
|
||||
await fs.writeFile(root + '/runtime/include/node/node_version.h', '#define NODE_MAJOR_VERSION 24\n#define NODE_MINOR_VERSION 18\n#define NODE_PATCH_VERSION 1\n');
|
||||
return { root, runtime: { execPath: root + '/runtime/bin/node', versions: { node: '24.18.1' }, env: { PATH: '/qa/bin', NODE_OPTIONS: '--require=untrusted', CFLAGS: '-march=armv7' } } };
|
||||
}
|
||||
|
||||
test('rebuilds only the bridge addon with matching local headers, bounded jobs and ARMv6 flags', async (t) => {
|
||||
const { root, runtime } = await fixture(t);
|
||||
const calls = [];
|
||||
await rebuildMigrationSqlite(root, { target, runtime, run: async (program, args, options) => {
|
||||
calls.push({ program, args, options });
|
||||
return { stdout: armv6 };
|
||||
} });
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].program, runtime.execPath);
|
||||
assert(calls[0].args.includes('--build-from-source'));
|
||||
assert(calls[0].args.includes('--napi_build_version=6'));
|
||||
assert(calls[0].args.includes('--nodedir=' + root + '/runtime'));
|
||||
assert.equal(calls[0].options.cwd, await fs.realpath(root + '/node_modules/sqlite3'));
|
||||
assert.equal(calls[0].options.env.MAKEFLAGS, '-j1');
|
||||
assert.equal(calls[0].options.env.NODE_OPTIONS, '--jitless --max-old-space-size=256');
|
||||
assert.equal(calls[0].options.env.CXXFLAGS, '-march=armv6 -mfpu=vfp -mfloat-abi=hard');
|
||||
assert.equal(calls[0].options.timeout, 1800000);
|
||||
assert.equal(calls[1].program, 'readelf');
|
||||
assert.equal(await fs.readFile(root + '/pnpm-lock.yaml', 'utf8'), 'fixed legacy lock');
|
||||
});
|
||||
|
||||
for (const fault of ['runtime-package', 'headers', 'compiler', 'armv7-output', 'lock-change'])
|
||||
test('fails closed for bridge rebuild fault: ' + fault, async (t) => {
|
||||
const { root, runtime } = await fixture(t);
|
||||
if (fault === 'runtime-package') await fs.writeFile(root + '/package.json', JSON.stringify({ dependencies: { '@mikro-orm/sql': '7' } }));
|
||||
if (fault === 'headers') runtime.versions.node = '22.17.0';
|
||||
let commands = 0;
|
||||
await assert.rejects(rebuildMigrationSqlite(root, { target, runtime, run: async () => {
|
||||
commands++;
|
||||
if (fault === 'compiler') throw new Error('compiler failed');
|
||||
if (fault === 'lock-change') await fs.appendFile(root + '/pnpm-lock.yaml', 'changed');
|
||||
return { stdout: fault === 'armv7-output' ? armv6.replace('v6', 'v7') : armv6 };
|
||||
} }), /Only a legacy|matching the existing Node|compiler failed|ARMv6 hard-float|changed the frozen lockfile/);
|
||||
if (['runtime-package', 'headers'].includes(fault)) assert.equal(commands, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user