Compare commits

..
Author SHA1 Message Date
whyour e3989f86aa feat: migrate SQLite runtime and verify complete upgrade bundles
Use MikroORM's official Node SQLite dialect with bounded database isolation, preserve legacy schema and data for rollback, and require Node 22.17 or newer. Update Debian runtimes and retain ARMv6 musl compatibility without a SQLite addon.

Complete short archive writes, stop orphaned SQL workers after owner death, preserve buffered startup requests, and prestart the archived backend on every platform. Cover real package installation, migration, failure recovery, and legacy database compatibility.
2026-10-22 12:22:27 +08:00
41 changed files with 185 additions and 2026 deletions
+11 -21
View File
@@ -550,14 +550,10 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# Unmodified amd64 packages for glibc and musl migration acceptance. This does not
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
needs: [build-debian, build-alpine]
strategy:
fail-fast: false
matrix:
libc: [glibc, musl]
needs: build-debian
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -565,7 +561,7 @@ jobs:
- uses: docker/setup-buildx-action@v4
- name: Export the official platform package from this run's image digest
env:
IMAGE_DIGEST: ${{ matrix.libc == 'musl' && needs.build-alpine.outputs.image-digest || needs.build-debian.outputs.image-digest }}
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
run: |
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
@@ -573,21 +569,20 @@ jobs:
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
- uses: actions/upload-artifact@v6
with:
name: npm-migration-bundle-${{ matrix.libc }}-${{ github.sha }}
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/*
if-no-files-found: error
retention-days: 3
verify-npm-package:
needs: [build-static, build-npm-migration-bundle, build-alpine]
needs: [build-static, build-npm-migration-bundle]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '22.17.0', '24', 'alpine']
container:
image: ${{ matrix.node == 'alpine' && format('ghcr.io/{0}@{1}', github.repository, needs.build-alpine.outputs.image-digest) || format('node:{0}-bookworm', matrix.node) }}
node: ['20.18.1', '22.17.0', '24']
container: node:${{ matrix.node }}-bookworm
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
@@ -596,16 +591,12 @@ jobs:
path: ${{ runner.temp }}/npm-panel/
- uses: actions/download-artifact@v6
with:
name: npm-migration-bundle-${{ matrix.node == 'alpine' && 'musl' || 'glibc' }}-${{ github.sha }}
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
if [ -f /etc/alpine-release ]; then
apk add --no-cache python3 openssl curl jq nginx procps make g++
else
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
fi
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
# The published legacy launcher resets PATH to standard directories.
# /usr/local is confined to this disposable job container; PM2_HOME
# remains private to each verification run.
@@ -714,8 +705,7 @@ jobs:
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
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 .
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
@@ -1,201 +0,0 @@
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
+3 -4
View File
@@ -503,10 +503,9 @@ export default (app: Router) => {
}),
async (req: Request<{ id: number }>, res: Response, next: NextFunction) => {
try {
const instances = await RunningInstanceModel.find(
{ cron_id: req.params.id },
{ orderBy: [{ started_at: 'desc' }] },
);
const instances = await RunningInstanceModel.find({
cron_id: req.params.id,
});
return res.send({ code: 200, data: instances });
} catch (e) {
return next(e);
+67 -74
View File
@@ -16,7 +16,6 @@ import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
import protectedPathCase from '../middlewares/protectedPathCase';
import { withAuthAvailability } from '../middlewares/authAvailability';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
const setting = value?.trim();
if (!setting) {
@@ -85,82 +84,76 @@ export default ({ app }: { app: Application }) => {
}
return next();
});
app.use(
withAuthAvailability(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
return next();
app.use(async (req: Request, res, next) => {
const pathLower = req.path.toLowerCase();
if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) {
return next();
}
const headerToken = getToken(req);
if (pathLower.startsWith('/open/')) {
const apps = await shareStore.getApps();
const doc = apps?.filter((x) =>
x.tokens?.find((y) => y.value === headerToken),
)?.[0];
if (doc && doc.tokens && doc.tokens.length > 0) {
const currentToken = doc.tokens.find((x) => x.value === headerToken);
const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/);
const key = keyMatch && keyMatch[1];
if (!doc.scopes.includes(key as AppScope)) {
const err = new UnauthorizedError('credentials_bad_scheme', {
message: t('暂无权限'),
});
return next(err);
}
if (
!currentToken ||
currentToken.expiration < Math.round(Date.now() / 1000)
) {
const err = new UnauthorizedError('invalid_token', {
message: t('Token 已失效'),
});
return next(err);
}
}
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
if (
!headerToken &&
originPath &&
config.apiWhiteList.includes(originPath)
) {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (
isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)
) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
}),
);
app.use(
withAuthAvailability(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next();
}
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
if (isInitialized) {
return res.send({ code: 450, message: t('未知错误') });
} else {
return next();
}
}),
);
}
const originPath = `${req.baseUrl}${pathLower === '/' ? '' : pathLower}`;
if (
!headerToken &&
originPath &&
config.apiWhiteList.includes(originPath)
) {
return next();
}
const authInfo = await shareStore.getAuthInfo();
if (isValidToken(authInfo, headerToken, req.platform, config.jwt.secret)) {
return next();
}
const errorCode = headerToken ? 'invalid_token' : 'credentials_required';
const errorMessage = headerToken ? t('Token 已失效') : t('请先登录');
const err = new UnauthorizedError(errorCode, { message: errorMessage });
next(err);
});
app.use(async (req, res, next) => {
const pathLower = req.path.toLowerCase();
if (
![
'/api/user/init',
'/api/user/notification/init',
'/open/user/init',
'/open/user/notification/init',
].includes(pathLower)
) {
return next();
}
const authInfo = (await shareStore.getAuthInfo()) || ({} as AuthInfo);
let isInitialized = !isDefaultAuthInfo(authInfo);
if (isInitialized) {
return res.send({ code: 450, message: t('未知错误') });
} else {
return next();
}
});
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.get('*', (req, res, next) => {
-18
View File
@@ -1,18 +0,0 @@
import type { RequestHandler } from 'express';
import { t } from '../shared/i18n';
// Express 4 does not forward rejected async middleware promises itself.
export function withAuthAvailability(handler: RequestHandler): RequestHandler {
return (req, res, next) => {
void Promise.resolve()
.then(() => handler(req, res, next))
.catch(() => {
res.setHeader('Retry-After', '1');
next(
Object.assign(new Error(t('认证服务暂时不可用,请稍后重试')), {
status: 503,
}),
);
});
};
}
-2
View File
@@ -1,8 +1,6 @@
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'认证服务暂时不可用,请稍后重试':
'Authentication service is temporarily unavailable; try again shortly.',
'升级任务启动失败,请检查日志后重试':
'Failed to start the update. Check the logs and try again.',
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
+1 -5
View File
@@ -16,11 +16,7 @@ export interface IKeyvStore {
}
const keyvSqlite = new CacheStore();
// A stopped database is unavailable, not a cache miss or an expired session.
export const keyvStore = new Keyv<IKeyvStore>({
store: keyvSqlite,
throwOnErrors: true,
});
export const keyvStore = new Keyv<IKeyvStore>({ store: keyvSqlite });
export const shareStore = {
getAuthInfo() {
+2 -15
View File
@@ -164,10 +164,7 @@ async function readState(tmp) {
}
}
async function getStatus(
tmp,
{ root = path.resolve(__dirname, '../../../..') } = {},
) {
async function getStatus(tmp) {
const state = await readState(tmp);
if (active.has(state.status)) {
let alive = false;
@@ -201,17 +198,7 @@ async function getStatus(
await handle.close();
}
}
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) };
return state;
}
// Manual restarts accept the currently prepared package, including one retained
@@ -419,8 +419,6 @@ async function stageBundle({
repair = repairDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
let pendingMigration = false,
requiresModern = false;
if (!local) {
const current = JSON.parse(
await fs
@@ -431,9 +429,6 @@ 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)
@@ -488,16 +483,7 @@ async function stageBundle({
indexUrl,
]);
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
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)];
const entry = index.packages?.[targetKey(target)];
if (
index.format !== 2 ||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
@@ -539,10 +525,6 @@ 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 = {
@@ -553,15 +535,6 @@ 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,28 +210,6 @@ 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-'),
@@ -321,12 +299,8 @@ 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')),
@@ -417,6 +391,5 @@ module.exports = {
prestartBackend,
dependencyFailure,
repairDependencies,
cleanSqliteBuildArtifacts,
probeLimits,
};
-16
View File
@@ -22,16 +22,8 @@ RUN set -x \
npm_config_target_libc=musl \
QL_BUILD_ONLY=1 pnpm install --prod --frozen-lockfile
# The amd64 APK runtime has crashed in native URL parsing on dev. Keep its
# URL implementation bundled with the pinned upstream musl Node binary.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.11-alpine
ARG TARGETARCH
ARG TARGETVARIANT
COPY --from=node-runtime-amd64 /usr/local/bin/node /tmp/node-runtime-amd64
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
@@ -70,10 +62,6 @@ RUN set -x \
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
mv /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
else rm /tmp/node-runtime-amd64; fi \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -98,10 +86,6 @@ COPY static/ /ql/static/
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
-16
View File
@@ -22,16 +22,8 @@ RUN set -x \
npm_config_target_libc=musl \
QL_BUILD_ONLY=1 pnpm install --prod --frozen-lockfile
# The amd64 APK runtime has crashed in native URL parsing on dev. Keep its
# URL implementation bundled with the pinned upstream musl Node binary.
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
FROM python:3.10-alpine
ARG TARGETARCH
ARG TARGETVARIANT
COPY --from=node-runtime-amd64 /usr/local/bin/node /tmp/node-runtime-amd64
ARG QL_MAINTAINER="whyour"
LABEL maintainer="${QL_MAINTAINER}"
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
@@ -70,10 +62,6 @@ RUN set -x \
netcat-openbsd \
unzip \
npm \
&& if [ "$TARGETARCH" = amd64 ]; then \
mv /tmp/node-runtime-amd64 /usr/local/bin/node; \
ln -sf /usr/local/bin/node /usr/bin/node; \
else rm /tmp/node-runtime-amd64; fi \
&& rm -rf /var/cache/apk/* \
&& apk update \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
@@ -98,10 +86,6 @@ COPY static/ /ql/static/
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
-22
View File
@@ -1,22 +0,0 @@
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
@@ -1,5 +0,0 @@
{
"sourceCommit": "99a4d0eff8431898d7638fefff305591fe326ed5",
"musl": "ghcr.io/whyour/qinglong@sha256:0847b0035083cd439c00ff3087392f67035b26842209c110ee976f51ec13fbcd",
"glibc": "ghcr.io/whyour/qinglong@sha256:d2775474223793a5dfe0060910acdcd9bab4c2b70ff6b2ef770ec536afb206f6"
}
+3 -9
View File
@@ -4,13 +4,7 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
## 在线升级
`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。
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧源码归档升级器可在运行时满足最低版本后迁移;已安装的旧格式 2 校验器仍要求 sqlite3 原生模块,首次切换到 node:sqlite 包需使用同批次新版独立安装器执行完整安装和重启,或先更新镜像。旧校验器会拒绝新依赖布局并保留旧服务;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → MikroORM/cron-parser/gRPC/undici 加载及 node:sqlite 内存库读写(旧包继续检查 SQLite/Sequelize) → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
@@ -30,7 +24,7 @@ gh workflow run build-docker-image.yml --ref develop
## 手动离线安装和升级
从 `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。
从 `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。
已有安装(默认应用并重启):
@@ -92,7 +86,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 线程阻塞,父进程被强杀后也会终止整个数据库进程并释放事务锁,避免阻止旧服务重启。
+1 -1
View File
@@ -148,7 +148,7 @@
"ip2region": "2.3.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"keyv": "5.6.0",
"keyv": "^5.2.3",
"lodash": "^4.17.21",
"multer": "2.4.0",
"node-cron": "4.6.0",
+16 -9
View File
@@ -89,8 +89,8 @@ dependencies:
specifier: ^9.0.2
version: 9.0.2
keyv:
specifier: 5.6.0
version: 5.6.0
specifier: ^5.2.3
version: 5.2.3
lodash:
specifier: ^4.17.21
version: 4.17.21
@@ -3186,8 +3186,10 @@ packages:
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
dev: false
/@keyv/serialize@1.1.1:
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
/@keyv/serialize@1.0.2:
resolution: {integrity: sha512-+E/LyaAeuABniD/RvUezWVXKpeuvwLEA9//nE9952zBaOdBd2mQ3pPoM8cUe2X6IcMByfuSLzmYqnYshG60+HQ==}
dependencies:
buffer: 6.0.3
dev: false
/@lezer/common@1.2.3:
@@ -6318,7 +6320,6 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/before@0.0.1:
resolution: {integrity: sha512-1J5SWbkoVJH9DTALN8igB4p+nPKZzPrJ/HomqBDLpfUvDXCdjdBmBUcH5McZfur0lftVssVU6BZug5NYh87zTw==}
@@ -6529,6 +6530,13 @@ packages:
isarray: 1.0.0
dev: true
/buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: false
/builtin-status-codes@3.0.0:
resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==}
dev: true
@@ -9521,7 +9529,6 @@ packages:
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
dev: true
/ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
@@ -10247,10 +10254,10 @@ packages:
json-buffer: 3.0.1
dev: true
/keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
/keyv@5.2.3:
resolution: {integrity: sha512-AGKecUfzrowabUv0bH1RIR5Vf7w+l4S3xtQAypKaUpTdIR1EbrAcTxHCrpo9Q+IWeUlFE2palRtgIQcgm+PQJw==}
dependencies:
'@keyv/serialize': 1.1.1
'@keyv/serialize': 1.0.2
dev: false
/kind-of@6.0.3:
-272
View File
@@ -1,272 +0,0 @@
// 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;
},
);
+6 -10
View File
@@ -42,10 +42,6 @@ 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-'));
@@ -84,8 +80,9 @@ async function buildBundle(root, output) {
const manifest = {
format: 2,
sourceCommit: build.sourceCommit,
version: pkg.version,
...(migrationBridge ? { migrationBridge } : {}),
version: JSON.parse(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
).version,
lockfileSha256: build.lockfileSha256,
target: {
platform: target.platform,
@@ -104,9 +101,9 @@ async function buildBundle(root, output) {
JSON.stringify(manifest),
);
console.log('Bundle inventory verified; writing archive');
const filename = `qinglong-${migrationBridge ? 'migration-' : ''}${
build.sourceCommit
}-${bundle.targetKey(target)}.tar.gz`;
const filename = `qinglong-${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');
@@ -131,7 +128,6 @@ 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),
};
+36 -65
View File
@@ -19,63 +19,39 @@ const platforms = [
'linux-s390x-glibc',
];
function releaseIndex(directory, commit, repository, options = {}) {
const {
version,
releaseTag = `upgrade-${commit}`,
migration = false,
} = options;
const { version, releaseTag = `upgrade-${commit}` } = 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-${
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 } : {}),
};
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 };
}
function publishBundles({
directory,
@@ -107,7 +83,6 @@ function publishBundles({
...releaseIndex(directory, commit, repository, {
version,
releaseTag: tag,
migration: true,
}),
version,
buildId,
@@ -140,12 +115,10 @@ function publishBundles({
};
const notes =
`Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
Object.entries(index.runtimePackages)
Object.entries(index.packages)
.map(([target, entry]) => `- [${target}](${entry.url})`)
.join('\n') +
`\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.';
`\n- [Standalone offline installer](${index.installer.url})`;
const ensureRelease = (ref, title) => {
if (!lookup(ref))
gh(
@@ -177,15 +150,13 @@ function publishBundles({
}
ensureRelease(tag, `Platform upgrade v${version} (${buildId})`);
const assets = platforms
.flatMap((target) =>
[false, true].flatMap((bridge) => {
const archive = path.join(
directory,
`qinglong-${bridge ? 'migration-' : ''}${commit}-${target}.tar.gz`,
);
return [archive, archive + '.json'];
}),
)
.flatMap((target) => {
const archive = path.join(
directory,
`qinglong-${commit}-${target}.tar.gz`,
);
return [archive, archive + '.json'];
})
.concat(
path.join(directory, 'install-bundle.cjs'),
path.join(directory, 'latest.json'),
-64
View File
@@ -1,64 +0,0 @@
// 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 };
+1 -21
View File
@@ -33,7 +33,6 @@ const requiredFiles = [
'shell/install-package.cjs',
'shell/upgrade-lock.yaml',
'shell/runtime-guard.cjs',
'shell/node-runtime-probe.cjs',
'shell/start.sh',
'shell/start-simplify.sh',
'shell/task.sh',
@@ -302,12 +301,7 @@ async function verifyPanel(input, output) {
signal: AbortSignal.timeout(5000),
});
const result = await response.json();
try {
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
} catch (error) {
error.status = response.status;
throw error;
}
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
return result.data;
};
const healthy = async () => {
@@ -332,7 +326,6 @@ async function verifyPanel(input, output) {
throw new Error(value.error);
} catch (error) {
if (
error.status !== 503 &&
!/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test(
error.message,
)
@@ -382,16 +375,6 @@ async function verifyPanel(input, output) {
).stdout.trim();
root = path.join(globalRoot, '@whyour/qinglong');
env.QL_DIR = root;
const runtimeProbes = [];
const checkRuntime = async () => {
const result = await run(
process.execPath,
[path.join(root, 'shell/node-runtime-probe.cjs')],
{ cwd: temp, env },
);
runtimeProbes.push(JSON.parse(result.stdout.trim()));
};
await checkRuntime();
// Use a subprocess: cached candidate modules must not mask missing files
// after old-package installation and the real npm overwrite below.
await run(
@@ -577,7 +560,6 @@ async function verifyPanel(input, output) {
const applying = await api('update/system', 'PUT');
await job(applying.id, 'completed');
const afterOnline = await healthy();
await checkRuntime();
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
await fs.access(path.join(root, 'node_modules/.pnpm'));
@@ -629,7 +611,6 @@ async function verifyPanel(input, output) {
timeout: 240000,
});
const afterOffline = await healthy();
await checkRuntime();
assert.notEqual(
afterOffline.build.instanceId,
afterOnline.build.instanceId,
@@ -675,7 +656,6 @@ async function verifyPanel(input, output) {
},
migratedFromNpmToOfficialPnpmLayout: true,
existingNodePreserved: true,
nativeUrlRuntimeProbes: runtimeProbes,
frontendAndBackendStarted: true,
checksumFailureKeptOldService: true,
prestartBeforeReady: true,
-62
View File
@@ -1,62 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { spawnSync } = require('node:child_process');
// Run in new processes: a healthy parent can outlive a broken native runtime.
function probeNodeRuntime(executable = process.execPath) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-url-'));
const source = `
const assert = require('node:assert/strict');
const { pathToFileURL, fileURLToPath } = require('node:url');
for (let i = 0; i < 1000; i++) {
assert.equal(new URL('/任务?q=' + i, 'https://example.com').hostname, 'example.com');
assert.equal(new URL('https://例子.测试').hostname, 'xn--fsqu00a.xn--0zwm56d');
assert.equal(new URL('http://[::1]:5700').port, '5700');
const filename = '/tmp/中文 path/' + i + '.cjs';
assert.equal(fileURLToPath(pathToFileURL(filename)), filename);
}
console.log('QINGLONG_NODE_URL_OK');
`;
const file = path.join(directory, 'probe.cjs');
fs.writeFileSync(file, source);
try {
for (let i = 0; i < 4; i++) {
const args = process.execArgv.includes('--jitless') ? ['--jitless'] : [];
args.push(...(i % 2 ? [file] : ['-e', source]));
const child = spawnSync(executable, args, {
encoding: 'utf8',
// Multi-architecture CI runs this same probe through QEMU.
timeout: 120000,
maxBuffer: 65536,
env: { ...process.env, NODE_OPTIONS: '' },
});
assert.equal(
child.status,
0,
`Node URL runtime probe failed (${i % 2 ? 'file' : 'eval'}): signal=${
child.signal
}; status=${child.status}; ${child.error?.message || child.stderr}`,
);
assert.match(child.stdout, /QINGLONG_NODE_URL_OK/);
}
return {
ok: true,
node: process.version,
architecture: process.arch,
ada: process.versions.ada,
processes: 4,
urlIterationsPerProcess: 1000,
nodeSha256: createHash('sha256')
.update(fs.readFileSync(executable))
.digest('hex'),
};
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
module.exports = { probeNodeRuntime };
if (require.main === module) console.log(JSON.stringify(probeNodeRuntime()));
+2 -4
View File
@@ -20,16 +20,14 @@ export default function UpgradeNotice({
const showNotice =
activeStatuses.includes(job.status) ||
job.status === 'ready' ||
job.status === 'failed' ||
Boolean(job.migrationRequired);
job.status === 'failed';
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 (
+2 -11
View File
@@ -15,7 +15,6 @@ export interface UpgradeJob {
version?: string;
error?: string;
log?: string;
migrationRequired?: boolean;
}
export const activeStatuses = ['starting', 'running', 'restarting'];
@@ -35,8 +34,6 @@ 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 '更新失败';
@@ -97,14 +94,8 @@ export function createUpgradeStore() {
) {
sessionStorage.removeItem(completionKey);
notification.success({
message: intl.get(
result.data.migrationRequired ? '升级器已更新' : '升级成功',
),
description: intl.get(
result.data.migrationRequired
? '当前仍运行旧版,请继续下载并重启以完成新版升级。'
: '已重新启动并运行新版本,无需再次重启。',
),
message: intl.get('升级成功'),
description: intl.get('已重新启动并运行新版本,无需再次重启。'),
duration: 8,
});
}
-8
View File
@@ -758,14 +758,6 @@
"暂存更新已变化,请重新下载更新。": "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,14 +758,6 @@
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
"启动版本与更新包不一致": "启动版本与更新包不一致",
"下载完成,等待重启": "下载完成,等待重启",
"升级器已更新,请继续升级新版": "升级器已更新,请继续升级新版",
"升级器已更新": "升级器已更新",
"当前仍运行旧版,请继续下载并重启以完成新版升级。": "当前仍运行旧版,请继续下载并重启以完成新版升级。",
"继续升级新版": "继续升级新版",
"升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。": "升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。",
"下载新版": "下载新版",
"更新索引尚未提供第二步运行包,请稍后重试": "更新索引尚未提供第二步运行包,请稍后重试",
"第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试": "第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试",
"升级成功": "升级成功",
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
}
+4 -26
View File
@@ -35,18 +35,6 @@ 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(
@@ -113,9 +101,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
});
const label = intl.get(upgradeStatusLabel(job));
const hasJob =
Boolean(job.migrationRequired) ||
(job.status !== 'idle' && job.status !== 'loading');
const hasJob = job.status !== 'idle' && job.status !== 'loading';
const actions = !active && (
<Space wrap>
<Button
@@ -124,15 +110,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
disabled={!connected || job.status === 'loading'}
onClick={ready ? showReloadModal : checkUpgrade}
>
{intl.get(
ready
? '重启并更新'
: job.migrationRequired
? '继续升级新版'
: '检查更新',
)}
{intl.get(ready ? '重启并更新' : '检查更新')}
</Button>
{!ready && !completed && !job.migrationRequired && (
{!ready && !completed && (
<Button
disabled={busy || !connected || job.status === 'loading'}
onClick={() => reloadSystem(false)}
@@ -168,9 +148,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
? intl.get(job.error || '更新失败,请检查网络及日志或稍后再试') ||
job.error
: intl.get(
job.migrationRequired && !ready && !active
? '当前仍运行旧版,请继续下载并重启以完成新版升级。'
: completed
completed
? '已重新启动并运行新版本,无需再次重启。'
: ready
? '更新已就绪,重启后应用新版本'
-129
View File
@@ -1,129 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs/promises');
const express = require('express');
const jwt = require('jsonwebtoken');
const load = require('../helpers/load-security-module.cjs');
test('authentication cache propagates database read and write failures', async () => {
const failure = new Error('synthetic unavailable database');
const CacheStore = load(path.resolve('back/data/cacheStore.ts'), {
'.': {
database: {
request: async () => {
throw failure;
},
},
},
}).CacheStore;
const { shareStore } = load(path.resolve('back/shared/store.ts'), {
'../data/cacheStore': { CacheStore },
});
await assert.rejects(shareStore.getAuthInfo(), (error) => error === failure);
await assert.rejects(
shareStore.updateAuthInfo({ username: 'qa' }),
(error) => error === failure,
);
});
test('HTTP authentication distinguishes unavailable storage from an invalid session', async (t) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-auth-availability-'),
);
const secret = 'synthetic-auth-availability';
const token = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
const auth = { username: 'qa', password: 'configured', token };
let unavailable = false;
let initialized = 0;
const read = async (value) => {
if (unavailable) throw new Error(`SQL failure must not expose ${token}`);
return value;
};
const router = express.Router();
router.get('/system/update-status', (_req, res) => res.json({ code: 200 }));
router.get('/envs', (_req, res) => res.json({ code: 200 }));
router.get('/health', (_req, res) => res.json({ code: 200 }));
router.put('/user/init', (_req, res) => {
initialized++;
res.json({ code: 200 });
});
const app = express();
load(path.resolve('back/loaders/express.ts'), {
'../config': {
rootPath: root,
uploadPath: path.join(root, 'upload'),
configPath: path.join(root, 'config'),
api: { prefix: '/api' },
apiWhiteList: ['/api/health', '/api/user/init'],
jwt: { secret },
},
'../api': () => router,
'../config/util': {
getToken: (req) =>
(req.headers.authorization || '').replace(/^Bearer /, ''),
getPlatform: () => 'desktop',
},
'../shared/store': {
shareStore: {
getAuthInfo: () => read(auth),
getApps: () =>
read([
{
scopes: ['envs'],
tokens: [
{ value: 'app-token', expiration: Date.now() / 1000 + 3600 },
],
},
]),
},
},
'../shared/i18n': { t: (value) => value },
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
'../data/open': {},
'../data/system': {},
}).default({ app });
const server = app.listen(0, '127.0.0.1');
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
await new Promise((resolve) => server.once('listening', resolve));
const request = async (route, value, method = 'GET') => {
const response = await fetch(
`http://127.0.0.1:${server.address().port}${route}`,
{
method,
headers: value ? { Authorization: `Bearer ${value}` } : {},
signal: AbortSignal.timeout(3000),
},
);
return { status: response.status, body: await response.text() };
};
assert.equal((await request('/api/system/update-status', token)).status, 200);
unavailable = true;
for (const [route, value, method] of [
['/api/system/update-status', token],
['/open/envs', 'app-token'],
['/api/user/init', undefined, 'PUT'],
]) {
const response = await request(route, value, method);
assert.equal(response.status, 503);
assert.equal(JSON.parse(response.body).code, 503);
assert.ok(!response.body.includes(token));
assert.ok(!response.body.includes('SQL failure'));
}
assert.equal(initialized, 0);
assert.equal((await request('/api/health')).status, 200);
unavailable = false;
assert.equal((await request('/api/system/update-status', token)).status, 200);
const invalid = jwt.sign({ unrecognized: true }, secret, {
algorithm: 'HS384',
expiresIn: '1h',
});
assert.equal(
(await request('/api/system/update-status', invalid)).status,
401,
);
});
-69
View File
@@ -1,69 +0,0 @@
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
@@ -1,322 +0,0 @@
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' });
});
@@ -1,50 +0,0 @@
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
@@ -1,105 +0,0 @@
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);
});
-25
View File
@@ -1,25 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { probeNodeRuntime } = require('../../shell/node-runtime-probe.cjs');
test('the retained Node handles URL, IDNA and file paths in fresh eval and file processes', () => {
const result = probeNodeRuntime();
assert.equal(result.ok, true);
assert.equal(result.processes, 4);
assert.match(result.nodeSha256, /^[0-9a-f]{64}$/);
});
test('a native child crash fails the runtime probe instead of accepting its healthy parent', (t) => {
if (process.platform === 'win32') return t.skip('POSIX crash fixture');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-crash-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const executable = path.join(directory, 'node');
fs.writeFileSync(executable, '#!/bin/sh\nkill -SEGV $$\n', { mode: 0o700 });
assert.throws(
() => probeNodeRuntime(executable),
/Node URL runtime probe failed.*SIGSEGV/,
);
});
+4 -17
View File
@@ -484,18 +484,14 @@ test('npm publication consumes the exact archive whose isolated installation pas
assert.deepEqual(verify.needs, [
'build-static',
'build-npm-migration-bundle',
'build-alpine',
]);
assert.ok(
verify.steps.some(
(step) =>
step.with?.name ===
"npm-migration-bundle-${{ matrix.node == 'alpine' && 'musl' || 'glibc' }}-${{ github.sha }}",
(step) => step.with?.name === 'npm-migration-bundle-${{ github.sha }}',
),
);
const migration = workflow.jobs['build-npm-migration-bundle'];
assert.deepEqual(migration.needs, ['build-debian', 'build-alpine']);
assert.deepEqual(migration.strategy.matrix.libc, ['glibc', 'musl']);
assert.equal(migration.needs, 'build-debian');
assert.ok(
migration.steps.some((step) =>
/Dockerfile.upgrade-bundle/.test(step.run || ''),
@@ -506,17 +502,8 @@ test('npm publication consumes the exact archive whose isolated installation pas
/publish-upgrade-bundles/.test(step.run || ''),
),
);
assert.deepEqual(verify.strategy.matrix.node, [
'20.18.1',
'22.17.0',
'24',
'alpine',
]);
assert.match(
verify.container.image,
/needs\.build-alpine\.outputs\.image-digest/,
);
assert.match(verify.container.image, /node:.*bookworm/);
assert.deepEqual(verify.strategy.matrix.node, ['20.18.1', '22.17.0', '24']);
assert.match(verify.container, /node:.*bookworm/);
const tools = verify.steps.find(
(step) => step.name === 'Install isolated service test tools',
);
-52
View File
@@ -1,52 +0,0 @@
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);
});
+21 -54
View File
@@ -36,38 +36,23 @@ function fixture(t) {
content = 'first',
version = options.version,
) => {
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,
},
}
: {}),
}),
);
}
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),
}),
);
}
fs.writeFileSync(
path.join(directory, 'install-bundle.cjs'),
`// ${content} installer`,
@@ -180,13 +165,8 @@ 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, 50);
assert.equal(Object.keys(index.runtimePackages).length, 12);
for (const entry of [
...Object.values(index.packages),
...Object.values(index.runtimePackages),
index.installer,
]) {
assert.equal(f.state.releases.get(tag).size, 26);
for (const entry of [...Object.values(index.packages), index.installer]) {
const url = new URL(entry.url),
filename = path.basename(url.pathname);
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
@@ -204,19 +184,6 @@ 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'
@@ -395,10 +362,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, 50);
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26);
assert.equal(
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
50,
26,
);
});
+2 -9
View File
@@ -7,7 +7,6 @@ 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 });
@@ -613,18 +612,12 @@ test('real SQLite and authentication cache retain concurrent process updates', a
external.client_id,
external.client_secret,
],
// 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 },
{ cwd: root, timeout: 20000, env: process.env },
),
),
);
const results = outcomes.map((outcome) => {
assert.equal(
outcome.status,
'fulfilled',
JSON.stringify(processFailure(outcome.reason)),
);
assert.equal(outcome.status, 'fulfilled', outcome.reason?.stderr);
return outcome.value;
});
const database = await service.findApps();
-91
View File
@@ -8,7 +8,6 @@ const {
dependencyFailure,
probeLimits,
repairDependencies,
cleanSqliteBuildArtifacts,
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
for (const exitCode of [0, 1])
@@ -262,96 +261,6 @@ 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'" },
+1 -50
View File
@@ -456,10 +456,7 @@ 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();
@@ -469,49 +466,3 @@ 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
@@ -1,28 +0,0 @@
// 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 };