Compare commits

...
Author SHA1 Message Date
whyour c9f1b2d4bb fix: preserve legacy SQLite addons loaded from build directories 2026-10-23 14:33:13 +08:00
whyour 5fdfd9077d fix: clean SQLite compiler artifacts before publishing repaired dependencies 2026-10-23 14:15:08 +08:00
whyour ce2e396b3e fix: remove legacy SQLite compiler artifacts from migration bundles 2026-10-23 14:04:42 +08:00
whyour 49cec13fcd test: classify subprocess crashes without exposing credentials 2026-10-23 13:50:37 +08:00
whyour 81de8f7094 fix: accept ARM1176-compatible ELF architecture attributes 2026-10-23 13:10:42 +08:00
whyour dce4f60c47 fix: restore locked build tools for the ARMv6 migration bridge 2026-10-23 12:16:28 +08:00
whyour 1623e89a71 fix: rebuild legacy migration SQLite for ARMv6 on the builder 2026-10-23 11:54:52 +08:00
whyour 1a49e81ffd fix: prevent second-hop migration from returning to a legacy backend 2026-10-23 11:08:20 +08:00
whyour 656b9419d6 test: budget startup for minimum-Node concurrent cache workers 2026-10-23 10:51:16 +08:00
whyour 8d2f67e263 Merge QEMU Node probe timeout fix into migration review 2026-10-23 10:40:27 +08:00
whyour 2a32f528f1 fix: initialize sample environment in migration candidate tests 2026-10-23 10:37:30 +08:00
whyour 384ce170c7 feat: bridge legacy panel upgrades in two verified steps 2026-10-23 10:33:00 +08:00
whyour cc001ecebd fix: allow emulated architectures time for Node URL probes 2026-10-23 01:53:28 +08:00
whyour 04c8a63518 Merge Alpine Node runtime fix into migration review branch 2026-10-23 01:49:18 +08:00
whyour 0a586fbd1d fix: preserve newest-first execution instance ordering 2026-10-23 01:42:58 +08:00
29 changed files with 1614 additions and 86 deletions
+2 -1
View File
@@ -714,7 +714,8 @@ jobs:
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
legacy=$(node -p "require('./docker/migration-bridge-images.json')['$BUNDLE_LIBC']")
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --build-arg "LEGACY_IMAGE=$legacy" --file docker/Dockerfile.upgrade-bridge --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
@@ -0,0 +1,201 @@
name: SQLite migration candidate
on:
pull_request:
branches: [develop]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: sqlite-migration-candidate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
build-static:
if: ${{ (github.event_name == 'pull_request' && github.head_ref == 'codex/sqlite-runtime-migration' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/codex/sqlite-runtime-migration') }}
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '22.17.0'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: npm test
- run: npm run build:front && npm run build:back && npm run build:info
- uses: actions/upload-artifact@v6
with:
name: migration-static-${{ env.SOURCE_COMMIT }}
path: static/
include-hidden-files: true
if-no-files-found: error
retention-days: 7
test-node24:
needs: build-static
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: npm test
build-images:
needs: build-static
runs-on: ubuntu-22.04
timeout-minutes: 90
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- libc: musl
file: docker/Dockerfile
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- libc: glibc
file: docker/Dockerfile.debian
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: actions/download-artifact@v6
with:
name: migration-static-${{ env.SOURCE_COMMIT }}
path: static/
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- uses: docker/build-push-action@v7
id: image
with:
context: .
file: ${{ matrix.file }}
platforms: ${{ matrix.platforms }}
build-args: |
QL_BRANCH=codex/sqlite-runtime-migration
SOURCE_COMMIT=${{ env.SOURCE_COMMIT }}
push: true
tags: ghcr.io/${{ github.repository }}:migration-candidate-${{ env.SOURCE_COMMIT }}-${{ matrix.libc }}
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.libc == 'musl' && 'alpine' || 'debian' }}
- name: Record the immutable candidate image
env:
IMAGE_DIGEST: ${{ steps.image.outputs.digest }}
BUNDLE_LIBC: ${{ matrix.libc }}
run: |
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
printf '%s\n' "ghcr.io/$GITHUB_REPOSITORY@$IMAGE_DIGEST" > "$BUNDLE_LIBC-image.txt"
- uses: actions/upload-artifact@v6
with:
name: migration-${{ matrix.libc }}-image-${{ env.SOURCE_COMMIT }}
path: ${{ matrix.libc }}-image.txt
retention-days: 7
build-bundles:
needs: build-images
runs-on: ubuntu-22.04
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- { platform: linux/amd64, libc: musl, name: x64-musl }
- { platform: linux/arm/v6, libc: musl, name: armv6-musl }
- { platform: linux/arm/v7, libc: musl, name: armv7-musl }
- { platform: linux/arm64, libc: musl, name: arm64-musl }
- { platform: linux/ppc64le, libc: musl, name: ppc64-musl }
- { platform: linux/s390x, libc: musl, name: s390x-musl }
- { platform: linux/386, libc: musl, name: ia32-musl }
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
- { platform: linux/s390x, libc: glibc, name: s390x-glibc }
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: actions/download-artifact@v6
with:
name: migration-${{ matrix.libc }}-image-${{ env.SOURCE_COMMIT }}
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and verify both online hops using the unchanged legacy client
env:
BUNDLE_PLATFORM: ${{ matrix.platform }}
BUNDLE_LIBC: ${{ matrix.libc }}
run: |
set -euo pipefail
image=$(cat "$BUNDLE_LIBC-image.txt")
legacy=$(node -p "require('./docker/migration-bridge-images.json')['$BUNDLE_LIBC']")
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --build-arg "LEGACY_IMAGE=$legacy" --file docker/Dockerfile.upgrade-bridge --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: migration-bundles-${{ matrix.name }}-${{ env.SOURCE_COMMIT }}
path: output/upgrade/*
if-no-files-found: error
retention-days: 7
verify-index:
needs: [build-bundles, test-node24]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ env.SOURCE_COMMIT }}
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile && npm --prefix cli ci
- uses: actions/download-artifact@v6
with:
pattern: migration-bundles-*-${{ env.SOURCE_COMMIT }}
path: output/upgrade/
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Validate every platform without publishing a channel
run: |
node -e "const fs=require('fs'),{releaseIndex}=require('./scripts/publish-upgrade-bundles.cjs'); const index=releaseIndex('output/upgrade',process.env.SOURCE_COMMIT,process.env.GITHUB_REPOSITORY,{migration:true}); fs.writeFileSync('output/upgrade/candidate-index.json',JSON.stringify(index));"
- uses: actions/upload-artifact@v6
with:
name: migration-candidate-index-${{ env.SOURCE_COMMIT }}
path: |
output/upgrade/candidate-index.json
output/upgrade/install-bundle.cjs
retention-days: 7
+4 -3
View File
@@ -503,9 +503,10 @@ export default (app: Router) => {
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const instances = await RunningInstanceModel.find({
cron_id: req.params.id,
});
const instances = await RunningInstanceModel.find(
{ cron_id: req.params.id },
{ orderBy: [{ started_at: 'desc' }] },
);
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
+15 -2
View File
@@ -164,7 +164,10 @@ async function readState(tmp) {
}
}
async function getStatus(tmp) {
async function getStatus(
tmp,
{ root = path.resolve(__dirname, '../../../..') } = {},
) {
const state = await readState(tmp);
if (active.has(state.status)) {
let alive = false;
@@ -198,7 +201,17 @@ async function getStatus(tmp) {
await handle.close();
}
}
return state;
const pkg = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
// Read the installed package on every poll: the worker's completion record
// survives replacement, while this marker disappears only with the new runtime.
return { ...state, migrationRequired: Boolean(pkg.qinglongMigrationBridge) };
}
// Manual restarts accept the currently prepared package, including one retained
@@ -419,6 +419,8 @@ async function stageBundle({
repair = repairDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
let pendingMigration = false,
requiresModern = false;
if (!local) {
const current = JSON.parse(
await fs
@@ -429,6 +431,9 @@ async function stageBundle({
}),
);
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
pendingMigration = Boolean(current.qinglongMigrationBridge);
requiresModern =
pendingMigration || Boolean(current.dependencies?.['@mikro-orm/sql']);
}
const target = await runtimeTarget();
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
@@ -483,7 +488,16 @@ async function stageBundle({
indexUrl,
]);
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
const entry = index.packages?.[targetKey(target)];
if (pendingMigration && !Object.hasOwn(index, 'runtimePackages'))
throw new Error('更新索引尚未提供第二步运行包,请稍后重试');
requiresModern ||= Object.hasOwn(index, 'runtimePackages');
// Old format-2 clients read packages and receive an updater bridge. Once
// installed, this selector uses only the actual runtime packages. A broken
// runtime entry must never fall back to the bridge or a different platform.
const packages = Object.hasOwn(index, 'runtimePackages')
? index.runtimePackages
: index.packages;
const entry = packages?.[targetKey(target)];
if (
index.format !== 2 ||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
@@ -525,6 +539,10 @@ async function stageBundle({
const manifest = JSON.parse(
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
);
if (manifest.migrationBridge)
throw new Error(
'This is a legacy upgrader bridge; download the runtime package instead',
);
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
throw new Error('Update index and bundle commit differ');
const selected = {
@@ -535,6 +553,15 @@ async function stageBundle({
await fs.rename(path.join(incoming, 'static'), selected.static);
await report({ phase: 'verifying', percent: null });
await verifyPayload(selected, manifest, target);
if (
requiresModern &&
!JSON.parse(
await fs.readFile(path.join(selected.source, 'package.json'), 'utf8'),
).dependencies?.['@mikro-orm/sql']
)
throw new Error(
'第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试',
);
const build = await verifyStatic(selected.source, selected.static, env);
if (build.sourceCommit !== manifest.sourceCommit)
throw new Error('Bundle and build commit differ');
@@ -210,6 +210,28 @@ async function prestartBackend(
}
}
async function cleanSqliteBuildArtifacts(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true }).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return [];
});
if (!entries.length) return;
const pkg = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8'));
const runtimePath = path.posix.normalize(pkg.binary?.module_path || '.');
const copiedRuntime = runtimePath.startsWith('lib/binding/');
// Our legacy fork copies its runtime to lib/binding. Other sqlite3 versions
// load build/Release directly: retain that addon and remove only the compiler
// helper directory. Never traverse a build-directory symlink while cleaning.
for (const entry of entries) {
if (entry.name !== 'build' && !/^build-tmp-napi-v\d+$/.test(entry.name)) continue;
const build = path.join(directory, entry.name);
await fs.rm(
copiedRuntime || entry.isSymbolicLink() ? build : path.join(build, 'node_gyp_bins'),
{ recursive: true, force: true },
);
}
}
async function repairDependencies(source, { target, signal, env }) {
const workspace = await fs.mkdtemp(
path.join(path.dirname(source), 'repair-'),
@@ -299,8 +321,12 @@ async function repairDependencies(source, { target, signal, env }) {
'--child-concurrency=1',
'--network-concurrency=2',
]);
if (pkg.dependencies?.sqlite3)
if (pkg.dependencies?.sqlite3) {
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
await cleanSqliteBuildArtifacts(
path.join(workspace, 'node_modules/sqlite3'),
);
}
if (
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
@@ -391,5 +417,6 @@ module.exports = {
prestartBackend,
dependencyFailure,
repairDependencies,
cleanSqliteBuildArtifacts,
probeLimits,
};
+22
View File
@@ -0,0 +1,22 @@
ARG LEGACY_IMAGE
ARG BASE_IMAGE
FROM ${LEGACY_IMAGE} AS legacy
FROM ${BASE_IMAGE} AS bridge
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
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; \
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \
rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \
done; \
else node /ql/scripts/build-migration-bridge.cjs /legacy /ql /out; fi
FROM scratch
COPY --from=bridge /out/ /
+5
View File
@@ -0,0 +1,5 @@
{
"sourceCommit": "99a4d0eff8431898d7638fefff305591fe326ed5",
"musl": "ghcr.io/whyour/qinglong@sha256:0847b0035083cd439c00ff3087392f67035b26842209c110ee976f51ec13fbcd",
"glibc": "ghcr.io/whyour/qinglong@sha256:d2775474223793a5dfe0060910acdcd9bab4c2b70ff6b2ef770ec536afb206f6"
}
+9 -3
View File
@@ -4,7 +4,13 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
## 在线升级
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧源码归档升级器可在运行时满足最低版本后迁移;已安装的旧格式 2 校验器仍要求 sqlite3 原生模块,首次切换到 node:sqlite 包需使用同批次新版独立安装器执行完整安装和重启,或先更新镜像。旧校验器会拒绝新依赖布局并保留旧服务;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。满足 Node 22.17 的旧格式 2 面板可在面板内分两步迁移,不需要手动运行安装器或更换镜像:第一次下载独立的兼容升级器包,预启动通过后手动重启;此时仍运行旧数据库后端,界面提示“升级器已更新,请继续升级新版”。点击“继续升级新版”完成第二次下载和新版后端预启动,再手动重启,健康与版本检查通过后才显示“升级成功”并隐藏侧栏状态。关闭页面、刷新或换浏览器不会丢失第二步提示;Node 不满足最低要求会在第一步准备阶段被旧校验器拒绝,旧服务继续运行。
兼容包单独命名为 `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 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
@@ -24,7 +30,7 @@ gh workflow run build-docker-image.yml --ref develop
## 手动离线安装和升级
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v<version>-<run-id>-<run-attempt>` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。
从 `upgrade-v<version>` 的说明或 `latest.json` 的 `runtimePackages` 中取得当前批次的下载链接,下载匹配设备的正常运行 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v<version>-<run-id>-<run-attempt>` release。不要下载 `qinglong-migration-` 包作为离线安装包。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。
已有安装(默认应用并重启):
@@ -86,7 +92,7 @@ master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,
## 数据库兼容与回滚
生产环境使用 MikroORM 7 的官方 NodeSqliteDialect。Sequelize 和 sqlite3 只作为开发测试依赖,用于验证旧库读写,不进入生产依赖。九张业务表及既有迁移记录保留原名和结构,不运行 ORM 自动改表;日期保持旧版 UTC 文本格式,JSON、ID、排序精度及 Keyv 缓存格式保持兼容,旧版可读新版写入的数据。
正常运行包使用 MikroORM 7 的官方 NodeSqliteDialect。Sequelize 和 sqlite3 只作为开发测试依赖,用于验证旧库读写,不进入正常运行包的生产依赖;独立的第一步兼容包仍携带固定旧镜像的真实旧依赖和后端,以满足已安装旧升级器的检查。九张业务表及既有迁移记录保留原名和结构,不运行 ORM 自动改表;日期保持旧版 UTC 文本格式,JSON、ID、排序精度及 Keyv 缓存格式保持兼容,旧版可读新版写入的数据。
每个后端进程延迟启动一个有界数据库子进程,避免同步 node:sqlite 阻塞 HTTP 和调度事件循环。事务独占请求队列,嵌套事务使用 SAVEPOINT;事务异常回滚。数据库进程退出或超时后拒绝后续请求、健康检查失败,不自动重放写入;恢复由服务重启完成。后端正常退出会终止其数据库子进程,预启动监督器还会终止整个临时进程组。数据库进程在打开 SQLite 前启动一个仅监视父进程的轻量线程;即使主 SQL 线程阻塞,父进程被强杀后也会终止整个数据库进程并释放事务锁,避免阻止旧服务重启。
+272
View File
@@ -0,0 +1,272 @@
// A separate first-hop package for installed format-2 verifiers. Its database
// backend and dependencies remain the pinned legacy implementation. Only the
// updater and frontend are replaced; the next download uses runtimePackages.
const fs = require('node:fs/promises');
const { createReadStream } = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const assert = require('node:assert/strict');
const { pipeline } = require('node:stream/promises');
const { once } = require('node:events');
const { collectBuildFiles } = require('../docker/build-manifest.cjs');
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) {
const oldBuild = await artifacts.verifyUpgrade(
legacy,
path.join(legacy, 'static'),
);
const build = await artifacts.verifyUpgrade(
current,
path.join(current, 'static'),
);
const pkg = await read(path.join(legacy, 'package.json'));
const next = await read(path.join(current, 'package.json'));
if (
oldBuild.sourceCommit !== baseline.sourceCommit ||
pkg.qinglongUpgradeFormat !== 2 ||
!pkg.dependencies?.sqlite3 ||
!pkg.dependencies?.sequelize ||
!next.dependencies?.['@mikro-orm/sql']
)
throw new Error(
'Migration bridge requires the pinned legacy backend and a modern runtime',
);
// Never copy user state, even if this builder is used outside Docker.
const excluded = new Set(['.git', '.tmp', '.env', 'data', 'output']);
await fs.cp(legacy, destination, {
recursive: true,
verbatimSymlinks: true,
filter: (file) =>
!excluded.has(path.relative(legacy, file).split(path.sep)[0]),
});
const maintenance = 'cli/src/internal/maintenance';
await fs.rm(path.join(destination, maintenance), {
recursive: true,
force: true,
});
await fs.cp(
path.join(current, maintenance),
path.join(destination, maintenance),
{ recursive: true },
);
await fs.rm(path.join(destination, 'static/dist'), {
recursive: true,
force: true,
});
await fs.cp(
path.join(current, 'static/dist'),
path.join(destination, 'static/dist'),
{ recursive: true },
);
const migrationBridge = {
format: 1,
legacySourceCommit: oldBuild.sourceCommit,
sourceCommit: build.sourceCommit,
targetVersion: next.version,
};
await fs.writeFile(
path.join(destination, 'package.json'),
JSON.stringify(
{
...pkg,
qinglongRuntimeMinimum: '22.17.0',
qinglongMigrationBridge: migrationBridge,
},
null,
2,
),
);
await fs.writeFile(
path.join(destination, 'static/build-info.json'),
JSON.stringify({
...oldBuild,
sourceCommit: build.sourceCommit,
files: collectBuildFiles(path.join(destination, 'static')),
}),
);
// No backend bytes or dependency lock may change in the updater hop.
assert.equal(
bundle.sha256(
await fs.readFile(path.join(destination, 'static/build/app.js')),
),
oldBuild.files['build/app.js'],
);
assert.equal(
bundle.sha256(await fs.readFile(path.join(destination, 'pnpm-lock.yaml'))),
oldBuild.lockfileSha256,
);
return migrationBridge;
}
async function verifyBothDownloads(legacy, current, directory, records) {
let index, workspace;
const files = new Map(
records.map((r) => ['/' + r.filename, path.join(directory, r.filename)]),
);
const server = http.createServer((req, res) => {
if (!index) {
res.writeHead(503);
res.end();
return;
}
if (req.url === '/latest.json') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(index));
} else if (files.has(req.url)) {
const record = records.find((r) => '/' + r.filename === req.url);
res.setHeader('Content-Length', record.size);
pipeline(createReadStream(files.get(req.url)), res).catch(() =>
res.destroy(),
);
} else {
res.writeHead(404);
res.end();
}
});
try {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
const entry = (r) => ({
size: r.size,
sha256: r.sha256,
url: base + '/' + r.filename,
});
const [bridge, runtime] = records;
index = {
format: 2,
sourceCommit: runtime.sourceCommit,
packages: { [bridge.target]: entry(bridge) },
runtimePackages: { [runtime.target]: entry(runtime) },
};
workspace = await fs.mkdtemp(path.join(directory, '.legacy-client-'));
const env = {
...process.env,
QL_BRANCH: 'develop',
QL_UPGRADE_INDEX_URL: base + '/latest.json',
QL_UPGRADE_FILE: '',
NO_PROXY: '127.0.0.1',
no_proxy: '127.0.0.1',
};
const old = require(path.join(
legacy,
'cli/src/internal/maintenance/upgradeArtifacts.cjs',
));
const firstTmp = path.join(workspace, 'first');
await fs.mkdir(firstTmp);
// These are the unchanged, genuinely installed old download, dependency
// smoke, prestart and readiness verifiers. No injected probe or verifier.
const selected = await old.stageUpgrade({
root: legacy,
tmp: firstTmp,
env,
run: (program, args, capture) =>
old.runProcess(env, program, args, capture),
});
await old.selectedUpgrade(firstTmp, 'develop', env);
assert(
(await read(path.join(selected.source, 'package.json')))
.qinglongMigrationBridge,
);
const upgraded = require(path.join(
selected.source,
'cli/src/internal/maintenance/upgradeArtifacts.cjs',
));
const secondTmp = path.join(workspace, 'second');
await fs.mkdir(secondTmp);
const next = await upgraded.stageUpgrade({
root: selected.source,
tmp: secondTmp,
env,
run: (program, args, capture) =>
upgraded.runProcess(env, program, args, capture),
});
await upgraded.selectedUpgrade(secondTmp, 'develop', env);
const pkg = await read(path.join(next.source, 'package.json'));
assert(pkg.dependencies['@mikro-orm/sql']);
assert.equal(pkg.qinglongMigrationBridge, undefined);
await assert.rejects(
fs.access(path.join(next.source, 'node_modules/sqlite3')),
{ code: 'ENOENT' },
);
await assert.rejects(
fs.access(path.join(next.source, 'node_modules/sequelize')),
{ code: 'ENOENT' },
);
const oldBundle = require(path.join(
legacy,
'cli/src/internal/maintenance/platformBundle.cjs',
));
const manifest = await read(
path.join(path.dirname(selected.source), 'bundle.json'),
);
assert.throws(
() =>
oldBundle.assertCompatible(
manifest,
{ ...manifest.target, libcVersion: manifest.target.libcMinimum },
'20.18.1',
),
/requires Node/,
);
return {
legacyOnlineVerified: true,
modernOnlineVerified: true,
legacyNode20Rejected: true,
};
} finally {
server.closeAllConnections();
if (server.listening) await new Promise((resolve) => server.close(resolve));
if (workspace) await fs.rm(workspace, { recursive: true, force: true });
}
}
async function buildMigrationBridge(legacy, current, output) {
legacy = path.resolve(legacy);
current = path.resolve(current);
output = path.resolve(output);
await fs.mkdir(output, { recursive: true });
const workspace = await fs.mkdtemp(path.join(output, '.migration-'));
let bridge;
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, [
bridge,
runtime,
]);
bridge = { ...bridge, migrationVerified: true, ...verification };
await fs.writeFile(
path.join(output, bridge.filename + '.json'),
JSON.stringify(bridge) + '\n',
);
console.log(JSON.stringify(bridge));
return bridge;
} catch (error) {
if (bridge)
await fs.rm(path.join(output, bridge.filename + '.json'), {
force: true,
});
throw error;
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
module.exports = { prepareBridge, verifyBothDownloads, buildMigrationBridge };
if (require.main === module)
buildMigrationBridge(process.argv[2], process.argv[3], process.argv[4]).catch(
(error) => {
console.error(error);
process.exitCode = 1;
},
);
+10 -6
View File
@@ -42,6 +42,10 @@ async function buildBundle(root, output) {
if (process.platform !== 'linux')
throw new Error('Release bundles must be built and probed on Linux');
const build = await verifyUpgrade(root, path.join(root, 'static'));
const pkg = JSON.parse(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
);
const migrationBridge = pkg.qinglongMigrationBridge;
await bundle.smokeDependencies(root);
await fs.mkdir(output, { recursive: true });
const workspace = await fs.mkdtemp(path.join(output, '.bundle-'));
@@ -80,9 +84,8 @@ async function buildBundle(root, output) {
const manifest = {
format: 2,
sourceCommit: build.sourceCommit,
version: JSON.parse(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
).version,
version: pkg.version,
...(migrationBridge ? { migrationBridge } : {}),
lockfileSha256: build.lockfileSha256,
target: {
platform: target.platform,
@@ -101,9 +104,9 @@ async function buildBundle(root, output) {
JSON.stringify(manifest),
);
console.log('Bundle inventory verified; writing archive');
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
target,
)}.tar.gz`;
const filename = `qinglong-${migrationBridge ? 'migration-' : ''}${
build.sourceCommit
}-${bundle.targetKey(target)}.tar.gz`;
await writeBundleArchive(workspace, path.join(output, filename));
// Verify the bytes users receive, not just the pre-archive build directory.
const roundtrip = path.join(workspace, 'roundtrip');
@@ -128,6 +131,7 @@ async function buildBundle(root, output) {
target: bundle.targetKey(target),
filename,
baseImage: process.env.QL_BUNDLE_BASE_IMAGE,
...(migrationBridge ? { migrationBridge } : {}),
size: bytes.length,
sha256: bundle.sha256(bytes),
};
+38 -9
View File
@@ -19,16 +19,23 @@ const platforms = [
'linux-s390x-glibc',
];
function releaseIndex(directory, commit, repository, options = {}) {
const { version, releaseTag = `upgrade-${commit}` } = options;
const {
version,
releaseTag = `upgrade-${commit}`,
migration = false,
} = options;
if (
!/^[a-f0-9]{40}$/.test(commit) ||
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
!/^[\w.+-]+$/.test(releaseTag)
)
throw new Error('Invalid release source');
const readPackages = (bridge) => {
const packages = {};
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
const filename = `qinglong-${
bridge ? 'migration-' : ''
}${commit}-${target}.tar.gz`;
const record = JSON.parse(
fs.readFileSync(path.join(directory, filename + '.json')),
);
@@ -36,7 +43,16 @@ function releaseIndex(directory, commit, repository, options = {}) {
if (
record.format !== 2 ||
record.sourceCommit !== commit ||
(version !== undefined && record.version !== version) ||
(version !== undefined &&
(bridge ? record.migrationBridge?.targetVersion : record.version) !==
version) ||
(bridge &&
(record.migrationVerified !== true ||
record.migrationBridge?.sourceCommit !== commit ||
record.migrationBridge?.legacySourceCommit !==
require('../docker/migration-bridge-images.json')
.sourceCommit)) ||
(!bridge && record.migrationBridge) ||
record.target !== target ||
record.filename !== filename ||
record.size !== bytes.length ||
@@ -51,7 +67,15 @@ function releaseIndex(directory, commit, repository, options = {}) {
)}/${filename}`,
};
}
return { format: 2, sourceCommit: commit, packages };
return packages;
};
const runtimePackages = readPackages(false);
return {
format: 2,
sourceCommit: commit,
packages: migration ? readPackages(true) : runtimePackages,
...(migration ? { runtimePackages } : {}),
};
}
function publishBundles({
directory,
@@ -83,6 +107,7 @@ function publishBundles({
...releaseIndex(directory, commit, repository, {
version,
releaseTag: tag,
migration: true,
}),
version,
buildId,
@@ -115,10 +140,12 @@ function publishBundles({
};
const notes =
`Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
Object.entries(index.packages)
Object.entries(index.runtimePackages)
.map(([target, entry]) => `- [${target}](${entry.url})`)
.join('\n') +
`\n- [Standalone offline installer](${index.installer.url})`;
`\n- [Standalone offline installer](${index.installer.url})\n\n` +
'Legacy format-2 panels update the upgrader first, then download the runtime. ' +
'The packages index contains the separate legacy bridge; runtimePackages contains the normal online/offline packages.';
const ensureRelease = (ref, title) => {
if (!lookup(ref))
gh(
@@ -150,13 +177,15 @@ function publishBundles({
}
ensureRelease(tag, `Platform upgrade v${version} (${buildId})`);
const assets = platforms
.flatMap((target) => {
.flatMap((target) =>
[false, true].flatMap((bridge) => {
const archive = path.join(
directory,
`qinglong-${commit}-${target}.tar.gz`,
`qinglong-${bridge ? 'migration-' : ''}${commit}-${target}.tar.gz`,
);
return [archive, archive + '.json'];
})
}),
)
.concat(
path.join(directory, 'install-bundle.cjs'),
path.join(directory, 'latest.json'),
+64
View File
@@ -0,0 +1,64 @@
// 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');
const { repairDependencies, cleanSqliteBuildArtifacts } = require('../cli/src/internal/maintenance/upgradePrestart.cjs');
function assertArmv6Addon(attributes) {
// Alpine's ARM CRT objects are ARMv6KZ (the ARM1176 architecture). Linking
// them raises a base-v6 object's ELF tag without introducing ARMv7/Thumb-2.
if (
!/Tag_CPU_arch:\s+v6(?:K|KZ|ZK)?\s*$/m.test(attributes) ||
/Tag_THUMB_ISA_use:\s+Thumb-2|Tag_FP_arch:\s+VFPv[3-9]|Tag_Advanced_SIMD_arch:\s+\S/.test(attributes) ||
!/Tag_ABI_VFP_args:\s+VFP registers/.test(attributes)
)
throw new Error('Legacy migration SQLite must use ARMv6 hard-float instructions: ' +
String(attributes).split('\n').filter((line) =>
/Tag_(CPU_arch|THUMB_ISA_use|FP_arch|ABI_VFP_args|Advanced_SIMD_arch):/.test(line),
).slice(0, 5).map((line) => line.trim()).join('; '));
}
async function rebuildMigrationSqlite(root, {
target,
run = promisify(execFile),
runtime = process,
repair = repairDependencies,
} = {}) {
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 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'));
// The Node 18 legacy builder skipped node-gyp 12's optional dependency.
// The existing frozen repair installs that locked graph using the current
// Node, then forces an ARMv6 source build with local headers and one job.
await repair(root, { target, env: runtime.env });
// Repair replaces node_modules, so resolve its new layout after completion.
const rebuilt = path.dirname(createRequire(path.join(root, 'package.json')).resolve('sqlite3/package.json'));
const addon = path.join(rebuilt, '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');
// node-pre-gyp copies the addon to lib/binding and then renames build to
// build-tmp-napi-v<N>. Both layouts contain compiler objects and an absolute
// node_gyp_bins/python3 link; none belongs in a portable runtime archive.
await cleanSqliteBuildArtifacts(rebuilt);
console.log('Legacy bridge SQLite rebuilt and verified for ARMv6 hard-float');
return true;
}
module.exports = { rebuildMigrationSqlite, assertArmv6Addon };
+2 -1
View File
@@ -28,7 +28,8 @@ function probeNodeRuntime(executable = process.execPath) {
args.push(...(i % 2 ? [file] : ['-e', source]));
const child = spawnSync(executable, args, {
encoding: 'utf8',
timeout: 20000,
// Multi-architecture CI runs this same probe through QEMU.
timeout: 120000,
maxBuffer: 65536,
env: { ...process.env, NODE_OPTIONS: '' },
});
+4 -2
View File
@@ -20,14 +20,16 @@ export default function UpgradeNotice({
const showNotice =
activeStatuses.includes(job.status) ||
job.status === 'ready' ||
job.status === 'failed';
job.status === 'failed' ||
Boolean(job.migrationRequired);
if (!showNotice && !open) return null;
const text = !connected
? intl.get('连接已断开,正在重新连接')
: intl.get(upgradeStatusLabel(job));
const label =
activeStatuses.includes(job.status) &&
job.phase === 'downloading' && job.percent != null
job.phase === 'downloading' &&
job.percent != null
? `${text} ${job.percent}%`
: text;
return (
+11 -2
View File
@@ -15,6 +15,7 @@ export interface UpgradeJob {
version?: string;
error?: string;
log?: string;
migrationRequired?: boolean;
}
export const activeStatuses = ['starting', 'running', 'restarting'];
@@ -34,6 +35,8 @@ export const phaseLabels: Record<string, string> = {
// Status is authoritative; old progress fields must not claim completion.
export function upgradeStatusLabel(job: UpgradeJob): string {
if (job.migrationRequired && ['idle', 'completed'].includes(job.status))
return '升级器已更新,请继续升级新版';
if (job.status === 'ready') return '下载完成,等待重启';
if (job.status === 'completed') return '升级成功';
if (job.status === 'failed') return '更新失败';
@@ -94,8 +97,14 @@ export function createUpgradeStore() {
) {
sessionStorage.removeItem(completionKey);
notification.success({
message: intl.get('升级成功'),
description: intl.get('已重新启动并运行新版本,无需再次重启。'),
message: intl.get(
result.data.migrationRequired ? '升级器已更新' : '升级成功',
),
description: intl.get(
result.data.migrationRequired
? '当前仍运行旧版,请继续下载并重启以完成新版升级。'
: '已重新启动并运行新版本,无需再次重启。',
),
duration: 8,
});
}
+8
View File
@@ -758,6 +758,14 @@
"暂存更新已变化,请重新下载更新。": "The staged update changed. Download the update again.",
"启动版本与更新包不一致": "The running build does not match the update package.",
"下载完成,等待重启": "Download complete, restart to update",
"升级器已更新,请继续升级新版": "Upgrader updated; continue to the new version",
"升级器已更新": "Upgrader updated",
"当前仍运行旧版,请继续下载并重启以完成新版升级。": "The previous version is still running. Download the new version and restart to finish upgrading.",
"继续升级新版": "Continue upgrading",
"升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。": "The upgrader is updated. Step 2 downloads and tests the new backend before you restart to apply it.",
"下载新版": "Download new version",
"更新索引尚未提供第二步运行包,请稍后重试": "The update index does not yet provide the second-step runtime. Please try again later.",
"第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试": "The second-step package still uses the legacy database backend. Your current service is unchanged. Please try again later.",
"升级成功": "Update successful",
"已重新启动并运行新版本,无需再次重启。": "Restarted and running the new version. No further restart is needed."
}
+8
View File
@@ -758,6 +758,14 @@
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
"启动版本与更新包不一致": "启动版本与更新包不一致",
"下载完成,等待重启": "下载完成,等待重启",
"升级器已更新,请继续升级新版": "升级器已更新,请继续升级新版",
"升级器已更新": "升级器已更新",
"当前仍运行旧版,请继续下载并重启以完成新版升级。": "当前仍运行旧版,请继续下载并重启以完成新版升级。",
"继续升级新版": "继续升级新版",
"升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。": "升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。",
"下载新版": "下载新版",
"更新索引尚未提供第二步运行包,请稍后重试": "更新索引尚未提供第二步运行包,请稍后重试",
"第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试": "第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试",
"升级成功": "升级成功",
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
}
+26 -4
View File
@@ -35,6 +35,18 @@ const CheckUpdate = ({ systemInfo }: any) => {
};
const checkUpgrade = async () => {
if (job.migrationRequired) {
Modal.confirm({
title: intl.get('继续升级新版'),
content: intl.get(
'升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。',
),
okText: intl.get('下载新版'),
cancelText: intl.get('以后再说'),
onOk: startUpdate,
});
return;
}
if (!upgradeStore.beginMutation()) return;
try {
const { code, data } = await request.put(
@@ -101,7 +113,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
});
const label = intl.get(upgradeStatusLabel(job));
const hasJob = job.status !== 'idle' && job.status !== 'loading';
const hasJob =
Boolean(job.migrationRequired) ||
(job.status !== 'idle' && job.status !== 'loading');
const actions = !active && (
<Space wrap>
<Button
@@ -110,9 +124,15 @@ const CheckUpdate = ({ systemInfo }: any) => {
disabled={!connected || job.status === 'loading'}
onClick={ready ? showReloadModal : checkUpgrade}
>
{intl.get(ready ? '重启并更新' : '检查更新')}
{intl.get(
ready
? '重启并更新'
: job.migrationRequired
? '继续升级新版'
: '检查更新',
)}
</Button>
{!ready && !completed && (
{!ready && !completed && !job.migrationRequired && (
<Button
disabled={busy || !connected || job.status === 'loading'}
onClick={() => reloadSystem(false)}
@@ -148,7 +168,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
? intl.get(job.error || '更新失败,请检查网络及日志或稍后再试') ||
job.error
: intl.get(
completed
job.migrationRequired && !ready && !active
? '当前仍运行旧版,请继续下载并重启以完成新版升级。'
: completed
? '已重新启动并运行新版本,无需再次重启。'
: ready
? '更新已就绪,重启后应用新版本'
+69
View File
@@ -0,0 +1,69 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const express = require('express');
const { DatabaseClient } = require('../../back/data/databaseClient');
const { Repository } = require('../../back/data/repository');
const { initialize } = require('../helpers/repository-fixture.cjs');
function mockModule(modulePath, exports) {
const filename = require.resolve(modulePath);
require.cache[filename] = {
id: filename,
filename,
loaded: true,
exports,
children: [],
paths: [],
};
}
test('instance API retains newest-first ordering independently of insertion order', async (t) => {
const db = new DatabaseClient(':memory:');
t.after(() => db.close());
await initialize(db);
const instances = new Repository('RunningInstance', () => db);
for (const [cron_id, started_at] of [
[7, 200],
[7, 300],
[8, 900],
[7, 100],
]) {
await instances.insert({ cron_id, started_at, status: 2 });
}
for (const service of ['cron', 'cronView']) {
mockModule(`../../back/services/${service}`, {
__esModule: true,
default: class {},
});
}
mockModule('../../back/schedule/client', { __esModule: true, default: {} });
mockModule('../../back/shared/i18n', { t: (message) => message });
mockModule('../../back/data/runningInstance', {
RunningInstanceModel: instances,
InstanceStatus: { running: 0 },
});
const app = express.Router();
require('../../back/api/cron').default(app);
const router = app.stack.find((layer) => layer.name === 'router').handle;
const handler = router.stack
.find((layer) => layer.route?.path === '/:id/instances')
.route.stack.at(-1).handle;
let response;
await handler(
{ params: { id: 7 } },
{
send: (body) => {
response = body;
},
},
(error) => {
throw error;
},
);
assert.equal(response.code, 200);
assert.deepEqual(
response.data.map((row) => row.started_at),
[300, 200, 100],
);
assert(response.data.every((row) => row.cron_id === 7));
});
+322
View File
@@ -0,0 +1,322 @@
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' });
});
@@ -0,0 +1,50 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const yaml = require('js-yaml');
const { platforms } = require('../../scripts/publish-upgrade-bundles.cjs');
test('candidate verification covers all platforms without writing development or production channels', () => {
const text = fs.readFileSync(
'.github/workflows/sqlite-migration-candidate.yml',
'utf8',
);
const workflow = yaml.load(text);
assert.match(
workflow.jobs['build-static'].if,
/head.repo.full_name == github.repository/,
);
assert.match(
workflow.jobs['build-static'].if,
/codex\/sqlite-runtime-migration/,
);
assert.equal(
workflow.env.SOURCE_COMMIT,
'${{ github.event.pull_request.head.sha || github.sha }}',
);
assert.deepEqual(
workflow.jobs['build-bundles'].strategy.matrix.include
.map((t) => `linux-${t.name}`)
.sort(),
[...platforms].sort(),
);
assert(
!/publish-static|node scripts\/publish-upgrade-bundles\.cjs|sync-static|pub\.sh/.test(
text,
),
);
const images = workflow.jobs['build-images'].steps.find(
(step) => step.with?.tags,
);
assert.match(images.with.tags, /:migration-candidate-/);
assert(!/latest|:develop|:debian-dev/.test(images.with.tags));
assert.equal(workflow.permissions.contents, 'read');
const production = yaml.load(
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
);
const exporter = production.jobs['build-upgrade-bundles'].steps.find(
(step) => step.env?.BUNDLE_PLATFORM,
);
assert.match(exporter.run, /Dockerfile\.upgrade-bridge/);
assert.match(exporter.run, /LEGACY_IMAGE=\$legacy/);
});
+105
View File
@@ -0,0 +1,105 @@
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('accepts the actual Alpine GCC ARMv6KZ CRT while retaining ARM1176 instruction limits', () => {
const crt = 'Tag_CPU_name: "6KZ"\nTag_CPU_arch: v6KZ\nTag_ARM_ISA_use: Yes\nTag_THUMB_ISA_use: Thumb-1\nTag_FP_arch: VFPv2\nTag_ABI_VFP_args: VFP registers\n';
for (const arch of ['v6KZ', 'v6ZK', 'v6K'])
assert.doesNotThrow(() => assertArmv6Addon(crt.replace('v6KZ', arch)));
for (const attributes of [crt.replace('v6KZ', 'v6T2'), crt.replace('v6KZ', 'v7'), crt.replace('Thumb-1', 'Thumb-2'), crt.replace('VFPv2', 'VFPv3-D16'), crt + 'Tag_Advanced_SIMD_arch: NEONv1\n'])
assert.throws(() => assertArmv6Addon(attributes), /ARMv6 hard-float/);
});
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', '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', JSON.stringify({ binary: { module_path: './lib/binding/napi-v{napi_build_version}-{platform}-{arch}' } }));
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('uses frozen repair even when the legacy image has no optional node-gyp', async (t) => {
const { root, runtime } = await fixture(t);
const calls = [];
let repaired = false;
await rebuildMigrationSqlite(root, { target, runtime, repair: async (source, options) => {
assert.equal(source, root);
assert.equal(options.target, target);
assert.equal(options.env, runtime.env);
repaired = true;
}, run: async (program, args, options) => {
assert.equal(repaired, true);
calls.push({ program, args, options });
return { stdout: armv6 };
} });
assert.equal(calls.length, 1);
assert.equal(calls[0].program, 'readelf');
assert(calls[0].args[1].endsWith('/lib/binding/napi-v6-linux-arm/node_sqlite3.node'));
assert.equal(await fs.readFile(root + '/pnpm-lock.yaml', 'utf8'), 'fixed legacy lock');
});
test('removes node-pre-gyp build directories and external compiler links while retaining the runtime addon', async (t) => {
const { root, runtime } = await fixture(t);
const sqlite = path.join(root, 'node_modules/sqlite3');
const addon = path.join(sqlite, 'lib/binding/napi-v6-linux-arm/node_sqlite3.node');
await fs.mkdir(path.dirname(addon), { recursive: true });
await fs.writeFile(addon, 'compiled ARM1176 addon');
const statics = path.join(root, 'static');
await fs.mkdir(statics);
const { inventory } = require('../../cli/src/internal/maintenance/platformBundle.cjs');
for (const name of ['build', 'build-tmp-napi-v3', 'build-tmp-napi-v6']) {
const directory = path.join(sqlite, name, 'node_gyp_bins');
await fs.mkdir(directory, { recursive: true });
await fs.symlink('/usr/bin/python3', path.join(directory, 'python3'));
}
await assert.rejects(inventory({ source: root, static: statics }), /Bundle link escapes/);
await rebuildMigrationSqlite(root, {
target, runtime,
repair: async () => {},
run: async () => ({ stdout: armv6 }),
});
assert.equal(await fs.readFile(addon, 'utf8'), 'compiled ARM1176 addon');
for (const name of ['build', 'build-tmp-napi-v3', 'build-tmp-napi-v6'])
await assert.rejects(fs.access(path.join(sqlite, name)), { code: 'ENOENT' });
await inventory({ source: root, static: statics });
});
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, repair: async () => {
commands++;
if (fault === 'compiler') throw new Error('compiler failed');
if (fault === 'lock-change') await fs.appendFile(root + '/pnpm-lock.yaml', 'changed');
}, run: async () => {
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);
});
+52
View File
@@ -0,0 +1,52 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { processFailure } = require('../helpers/process-failure.cjs');
test('process failure diagnostics classify native crashes without credentials or raw output', () => {
const secret = 'private-client-secret';
const summary = processFailure({
code: null,
signal: 'SIGTRAP',
killed: false,
cmd: `node worker.cjs ${secret}`,
message: secret,
stdout: JSON.stringify({ token: secret }),
stderr: `${secret}\nCheck failed: (data_) != nullptr.\ncompiler/heap-refs.h\nv8::internal::compiler::JSHeapBroker\n`,
});
assert.equal(JSON.stringify(summary).includes(secret), false);
assert.equal(summary.signal, 'SIGTRAP');
assert.equal(summary.fatal.v8Check, true);
assert.equal(summary.fatal.heapBroker, true);
assert.equal(summary.fatal.compiler, true);
assert.equal(summary.fatal.outOfMemory, false);
assert.equal(summary.fatal.threadCreation, false);
});
test('process failure diagnostics retain timeout and resource classes without arbitrary error fields', () => {
const runtime = { versions: { node: '24.21.0' }, arch: 'x64' };
const summary = processFailure(
{
code: 1,
signal: 'SIGTERM private-token',
killed: true,
stderr: 'pthread_create: resource unavailable; heap out of memory',
},
runtime,
);
assert.deepEqual(summary, {
code: 1,
signal: null,
killed: true,
node: '24.21.0',
arch: 'x64',
fatal: {
threadCreation: true,
outOfMemory: true,
v8Check: false,
heapBroker: false,
compiler: false,
},
});
assert.equal(processFailure({ code: 'private-token' }).code, null);
assert.equal(processFailure().signal, null);
});
+39 -6
View File
@@ -36,8 +36,11 @@ function fixture(t) {
content = 'first',
version = options.version,
) => {
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
for (const target of platforms)
for (const bridge of [false, true]) {
const filename = `qinglong-${
bridge ? 'migration-' : ''
}${commit}-${target}.tar.gz`;
const bytes = Buffer.from(`${version}/${content}/${target}`);
fs.writeFileSync(path.join(directory, filename), bytes);
fs.writeFileSync(
@@ -50,6 +53,18 @@ function fixture(t) {
filename,
size: bytes.length,
sha256: sha256(bytes),
...(bridge
? {
migrationVerified: true,
migrationBridge: {
sourceCommit: commit,
targetVersion: version,
legacySourceCommit:
require('../../docker/migration-bridge-images.json')
.sourceCommit,
},
}
: {}),
}),
);
}
@@ -165,8 +180,13 @@ test('publishes every platform and installer before advancing version and legacy
assert.equal(Object.keys(index.packages).length, 12);
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
const tag = 'upgrade-build-v2.24.0-100-1';
assert.equal(f.state.releases.get(tag).size, 26);
for (const entry of [...Object.values(index.packages), index.installer]) {
assert.equal(f.state.releases.get(tag).size, 50);
assert.equal(Object.keys(index.runtimePackages).length, 12);
for (const entry of [
...Object.values(index.packages),
...Object.values(index.runtimePackages),
index.installer,
]) {
const url = new URL(entry.url),
filename = path.basename(url.pathname);
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
@@ -184,6 +204,19 @@ test('publishes every platform and installer before advancing version and legacy
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
});
test('missing or unverified bridges cannot advance an index whose runtime packages are complete', (t) => {
const f = fixture(t);
const file = path.join(
f.directory,
`qinglong-migration-${f.options.commit}-${platforms[0]}.tar.gz.json`,
);
const receipt = JSON.parse(fs.readFileSync(file));
delete receipt.migrationVerified;
fs.writeFileSync(file, JSON.stringify(receipt));
assert.throws(() => f.publish(), /Unverified platform bundle/);
assert.equal(f.state.calls.length, 0);
});
for (const sameCommit of [false, true])
test(`same-version replacement updates both indexes and retains in-flight downloads (${
sameCommit ? 'same commit rerun' : 'new commit'
@@ -362,10 +395,10 @@ test('automatic development publication shares musl/glibc packages without chang
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26);
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 50);
assert.equal(
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
26,
50,
);
});
+9 -2
View File
@@ -7,6 +7,7 @@ const os = require('node:os');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { processFailure } = require('../helpers/process-failure.cjs');
const environment = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-env-'));
fs.writeFileSync(path.join(environment, '.env'), '');
fs.mkdirSync(path.join(environment, 'data/db'), { recursive: true });
@@ -612,12 +613,18 @@ test('real SQLite and authentication cache retain concurrent process updates', a
external.client_id,
external.client_secret,
],
{ cwd: root, timeout: 20000, env: process.env },
// Seven ts-node processes also start isolated database workers. Their
// compilation/startup on minimum-Node CI is outside the lock operation.
{ cwd: root, timeout: 60000, env: process.env },
),
),
);
const results = outcomes.map((outcome) => {
assert.equal(outcome.status, 'fulfilled', outcome.reason?.stderr);
assert.equal(
outcome.status,
'fulfilled',
JSON.stringify(processFailure(outcome.reason)),
);
return outcome.value;
});
const database = await service.findApps();
+91
View File
@@ -8,6 +8,7 @@ const {
dependencyFailure,
probeLimits,
repairDependencies,
cleanSqliteBuildArtifacts,
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
for (const exitCode of [0, 1])
@@ -261,6 +262,96 @@ test('ARMv6 repair isolates configuration, uses frozen install, one compiler job
assert.deepEqual((await fs.readdir(root)).sort(), ['bin', 'source']);
});
test('successful legacy repair publishes only portable runtime files from a pnpm alias', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-portable-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
const outside = path.join(root, 'outside');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.mkdir(outside);
await fs.writeFile(path.join(outside, 'keep'), 'unrelated file');
await fs.writeFile(
path.join(source, 'node_modules/old'),
'previous dependency',
);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({
packageManager: 'pnpm@8.3.1',
dependencies: { sqlite3: 'npm:@whyour/sqlite3@1.1.2' },
}),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
const fs = require('node:fs');
if (process.argv[2] === '--version') { console.log('8.3.1'); process.exit(0); }
const pkg = 'node_modules/.pnpm/@whyour+sqlite3@1.1.2/node_modules/@whyour/sqlite3';
if (process.argv[2] === 'install') {
fs.mkdirSync(pkg + '/lib/binding/napi-v6-linux-arm', {recursive:true});
fs.writeFileSync(pkg + '/package.json', JSON.stringify({binary:{module_path:'./lib/binding/napi-v{napi_build_version}-{platform}-{arch}'}}));
fs.symlinkSync('.pnpm/@whyour+sqlite3@1.1.2/node_modules/@whyour/sqlite3', 'node_modules/sqlite3');
} else if (process.argv[2] === 'rebuild') {
fs.writeFileSync(pkg + '/lib/binding/napi-v6-linux-arm/node_sqlite3.node', 'compiled addon');
for (const name of ['build', 'build-tmp-napi-v6']) {
fs.mkdirSync(pkg + '/' + name + '/node_gyp_bins', {recursive:true});
fs.symlinkSync('/usr/bin/python3', pkg + '/' + name + '/node_gyp_bins/python3');
}
fs.symlinkSync(${JSON.stringify(outside)}, pkg + '/build-tmp-napi-v3');
} else { throw Error('unexpected command'); }
`,
{ mode: 0o755 },
);
await repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: bin },
});
const sqlite = path.join(source, 'node_modules/sqlite3');
assert.equal(
await fs.readFile(
path.join(sqlite, 'lib/binding/napi-v6-linux-arm/node_sqlite3.node'),
'utf8',
),
'compiled addon',
);
for (const name of ['build', 'build-tmp-napi-v3', 'build-tmp-napi-v6'])
await assert.rejects(fs.lstat(path.join(sqlite, name)), { code: 'ENOENT' });
assert.equal(
await fs.readFile(path.join(outside, 'keep'), 'utf8'),
'unrelated file',
);
await assert.rejects(fs.access(path.join(source, 'node_modules/old')), {
code: 'ENOENT',
});
const {
inventory,
} = require('../../cli/src/internal/maintenance/platformBundle.cjs');
await inventory({ source });
});
test('cleanup retains sqlite3 runtimes loaded from build/Release and never follows a build symlink', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-sqlite-release-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const sqlite = path.join(root, 'sqlite3'), outside = path.join(root, 'outside');
await fs.mkdir(path.join(sqlite, 'build/Release'), { recursive: true });
await fs.mkdir(path.join(sqlite, 'build/node_gyp_bins'));
await fs.mkdir(path.join(outside, 'node_gyp_bins'), { recursive: true });
await fs.writeFile(path.join(outside, 'node_gyp_bins/keep'), 'untouched');
await fs.writeFile(path.join(sqlite, 'package.json'), JSON.stringify({ name: 'sqlite3', binary: { napi_versions: [3, 6] } }));
await fs.writeFile(path.join(sqlite, 'build/Release/node_sqlite3.node'), 'runtime addon');
await fs.symlink('/usr/bin/python3', path.join(sqlite, 'build/node_gyp_bins/python3'));
await fs.symlink(outside, path.join(sqlite, 'build-tmp-napi-v3'));
await cleanSqliteBuildArtifacts(sqlite);
assert.equal(await fs.readFile(path.join(sqlite, 'build/Release/node_sqlite3.node'), 'utf8'), 'runtime addon');
await assert.rejects(fs.access(path.join(sqlite, 'build/node_gyp_bins')), { code: 'ENOENT' });
await assert.rejects(fs.lstat(path.join(sqlite, 'build-tmp-napi-v3')), { code: 'ENOENT' });
assert.equal(await fs.readFile(path.join(outside, 'node_gyp_bins/keep'), 'utf8'), 'untouched');
await require('../../cli/src/internal/maintenance/platformBundle.cjs').inventory({ source: sqlite });
});
test('repair classification excludes application, platform, config, database, OOM and timeouts', () => {
for (const error of [
{ code: 'MODULE_NOT_FOUND', message: "Cannot find module './api/missing'" },
+50 -1
View File
@@ -456,7 +456,10 @@ test('sidebar and dialog use the same authoritative status even when old phase f
);
f.store.setJob({ status: 'ready', phase: 'downloading', percent: 62 });
notice.render();
assert.equal(notice.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
assert.equal(
notice.nodes('button')[0].props['aria-label'],
'下载完成,等待重启',
);
f.store.setJob({ status: 'completed', phase: 'ready' });
assert.equal(notice.render(), null);
panel.render();
@@ -466,3 +469,49 @@ test('sidebar and dialog use the same authoritative status even when old phase f
.some((node) => node.props.children.includes?.('升级成功')),
);
});
test('the first hop stays visible and offers the second download without an unrelated version check', async () => {
const f = fixture(),
notice = f.component('src/components/UpgradeNotice.tsx'),
panel = f.component('src/pages/setting/checkUpdate.tsx');
f.store.setJob({
id: 'bridge',
status: 'completed',
migrationRequired: true,
});
notice.render();
panel.render();
assert.equal(
notice.nodes('button')[0].props['aria-label'],
'升级器已更新,请继续升级新版',
);
const button = panel
.nodes('Button')
.find((n) => n.props.children === '继续升级新版');
assert(button);
await button.props.onClick();
assert.equal(f.confirms.at(-1).title, '继续升级新版');
assert.equal(f.requests.length, 0);
await f.confirms.at(-1).onOk();
assert.equal(f.requests[0].url, '/panel/api/system/update');
f.store.setJob({ status: 'completed', migrationRequired: false });
assert.equal(notice.render(), null);
});
test('a completed updater hop never emits the final runtime success notification', async () => {
const f = fixture();
f.storage.setItem(f.hooks.completionKey, 'bridge');
f.responses.push({
id: 'bridge',
status: 'completed',
migrationRequired: true,
});
const unsubscribe = f.store.subscribe(() => {});
await f.settle();
assert.equal(f.notifications[0].message, '升级器已更新');
assert.equal(
f.notifications[0].description,
'当前仍运行旧版,请继续下载并重启以完成新版升级。',
);
unsubscribe();
});
+28
View File
@@ -0,0 +1,28 @@
// execFile errors contain the command (including credentials), stdout and
// stderr. Keep CI diagnostics useful without publishing any of those values.
function processFailure(error, runtime = process) {
const stderr = String(error?.stderr || '');
return {
code: Number.isInteger(error?.code) ? error.code : null,
signal: /^SIG[A-Z0-9]+$/.test(error?.signal || '') ? error.signal : null,
killed: error?.killed === true,
node: runtime.versions.node,
arch: runtime.arch,
fatal: {
threadCreation:
/uv_thread_create|pthread_create|Failed to start thread|failed to create[^\n]*thread/i.test(
stderr,
),
outOfMemory: /heap out of memory|Allocation failed|OutOfMemory/i.test(
stderr,
),
v8Check: /Check failed:|Fatal error in/i.test(stderr),
heapBroker: /JSHeapBroker|compiler\/heap-refs/.test(stderr),
compiler: /v8::internal::(?:compiler|maglev)|PersistentHandles/.test(
stderr,
),
},
};
}
module.exports = { processFailure };