diff --git a/docker/Dockerfile.upgrade-bridge b/docker/Dockerfile.upgrade-bridge index 012e838a..5951fd3c 100644 --- a/docker/Dockerfile.upgrade-bridge +++ b/docker/Dockerfile.upgrade-bridge @@ -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; \ diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index 331ed49e..8472a335 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -8,6 +8,8 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中 兼容包单独命名为 `qinglong-migration--.tar.gz`,来自固定摘要、固定源码提交的旧镜像,保留真实旧 SQLite/Sequelize 依赖和旧数据库后端,只替换升级器、前端和构建身份;它不伪造 SQLite 模块,也不执行用户数据迁移。正常运行包仍为 `qinglong--.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 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。 diff --git a/scripts/build-migration-bridge.cjs b/scripts/build-migration-bridge.cjs index abbceb9a..6582370d 100644 --- a/scripts/build-migration-bridge.cjs +++ b/scripts/build-migration-bridge.cjs @@ -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, [ diff --git a/scripts/rebuild-migration-sqlite.cjs b/scripts/rebuild-migration-sqlite.cjs new file mode 100644 index 00000000..b577ebf0 --- /dev/null +++ b/scripts/rebuild-migration-sqlite.cjs @@ -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 }; diff --git a/test/back/migration-sqlite-armv6.test.cjs b/test/back/migration-sqlite-armv6.test.cjs new file mode 100644 index 00000000..30f926a8 --- /dev/null +++ b/test/back/migration-sqlite-armv6.test.cjs @@ -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); + });