mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f1b2d4bb | ||
|
|
5fdfd9077d | ||
|
|
ce2e396b3e | ||
|
|
49cec13fcd | ||
|
|
81de8f7094 | ||
|
|
dce4f60c47 | ||
|
|
1623e89a71 | ||
|
|
1a49e81ffd | ||
|
|
656b9419d6 | ||
|
|
8d2f67e263 | ||
|
|
2a32f528f1 | ||
|
|
384ce170c7 | ||
|
|
cc001ecebd | ||
|
|
04c8a63518 | ||
|
|
0a586fbd1d |
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/ /
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sourceCommit": "99a4d0eff8431898d7638fefff305591fe326ed5",
|
||||
"musl": "ghcr.io/whyour/qinglong@sha256:0847b0035083cd439c00ff3087392f67035b26842209c110ee976f51ec13fbcd",
|
||||
"glibc": "ghcr.io/whyour/qinglong@sha256:d2775474223793a5dfe0060910acdcd9bab4c2b70ff6b2ef770ec536afb206f6"
|
||||
}
|
||||
@@ -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 线程阻塞,父进程被强杀后也会终止整个数据库进程并释放事务锁,避免阻止旧服务重启。
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -19,39 +19,63 @@ 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 packages = {};
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||
const record = JSON.parse(
|
||||
fs.readFileSync(path.join(directory, filename + '.json')),
|
||||
);
|
||||
const bytes = fs.readFileSync(path.join(directory, filename));
|
||||
if (
|
||||
record.format !== 2 ||
|
||||
record.sourceCommit !== commit ||
|
||||
(version !== undefined && record.version !== version) ||
|
||||
record.target !== target ||
|
||||
record.filename !== filename ||
|
||||
record.size !== bytes.length ||
|
||||
record.sha256 !== sha256(bytes)
|
||||
)
|
||||
throw new Error(`Unverified platform bundle: ${target}`);
|
||||
packages[target] = {
|
||||
size: record.size,
|
||||
sha256: record.sha256,
|
||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||
releaseTag,
|
||||
)}/${filename}`,
|
||||
};
|
||||
}
|
||||
return { format: 2, sourceCommit: commit, packages };
|
||||
const readPackages = (bridge) => {
|
||||
const packages = {};
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${
|
||||
bridge ? 'migration-' : ''
|
||||
}${commit}-${target}.tar.gz`;
|
||||
const record = JSON.parse(
|
||||
fs.readFileSync(path.join(directory, filename + '.json')),
|
||||
);
|
||||
const bytes = fs.readFileSync(path.join(directory, filename));
|
||||
if (
|
||||
record.format !== 2 ||
|
||||
record.sourceCommit !== commit ||
|
||||
(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 ||
|
||||
record.sha256 !== sha256(bytes)
|
||||
)
|
||||
throw new Error(`Unverified platform bundle: ${target}`);
|
||||
packages[target] = {
|
||||
size: record.size,
|
||||
sha256: record.sha256,
|
||||
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
|
||||
releaseTag,
|
||||
)}/${filename}`,
|
||||
};
|
||||
}
|
||||
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) => {
|
||||
const archive = path.join(
|
||||
directory,
|
||||
`qinglong-${commit}-${target}.tar.gz`,
|
||||
);
|
||||
return [archive, archive + '.json'];
|
||||
})
|
||||
.flatMap((target) =>
|
||||
[false, true].flatMap((bridge) => {
|
||||
const archive = path.join(
|
||||
directory,
|
||||
`qinglong-${bridge ? 'migration-' : ''}${commit}-${target}.tar.gz`,
|
||||
);
|
||||
return [archive, archive + '.json'];
|
||||
}),
|
||||
)
|
||||
.concat(
|
||||
path.join(directory, 'install-bundle.cjs'),
|
||||
path.join(directory, 'latest.json'),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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: '' },
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -758,6 +758,14 @@
|
||||
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
|
||||
"启动版本与更新包不一致": "启动版本与更新包不一致",
|
||||
"下载完成,等待重启": "下载完成,等待重启",
|
||||
"升级器已更新,请继续升级新版": "升级器已更新,请继续升级新版",
|
||||
"升级器已更新": "升级器已更新",
|
||||
"当前仍运行旧版,请继续下载并重启以完成新版升级。": "当前仍运行旧版,请继续下载并重启以完成新版升级。",
|
||||
"继续升级新版": "继续升级新版",
|
||||
"升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。": "升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。",
|
||||
"下载新版": "下载新版",
|
||||
"更新索引尚未提供第二步运行包,请稍后重试": "更新索引尚未提供第二步运行包,请稍后重试",
|
||||
"第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试": "第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试",
|
||||
"升级成功": "升级成功",
|
||||
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? '更新已就绪,重启后应用新版本'
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -36,23 +36,38 @@ function fixture(t) {
|
||||
content = 'first',
|
||||
version = options.version,
|
||||
) => {
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${commit}-${target}.tar.gz`;
|
||||
const bytes = Buffer.from(`${version}/${content}/${target}`);
|
||||
fs.writeFileSync(path.join(directory, filename), bytes);
|
||||
fs.writeFileSync(
|
||||
path.join(directory, filename + '.json'),
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit: commit,
|
||||
version,
|
||||
target,
|
||||
filename,
|
||||
size: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
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(
|
||||
path.join(directory, filename + '.json'),
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit: commit,
|
||||
version,
|
||||
target,
|
||||
filename,
|
||||
size: bytes.length,
|
||||
sha256: sha256(bytes),
|
||||
...(bridge
|
||||
? {
|
||||
migrationVerified: true,
|
||||
migrationBridge: {
|
||||
sourceCommit: commit,
|
||||
targetVersion: version,
|
||||
legacySourceCommit:
|
||||
require('../../docker/migration-bridge-images.json')
|
||||
.sourceCommit,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'install-bundle.cjs'),
|
||||
`// ${content} installer`,
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user