mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 00:42:01 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3989f86aa |
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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/ /
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"sourceCommit": "99a4d0eff8431898d7638fefff305591fe326ed5",
|
||||
"musl": "ghcr.io/whyour/qinglong@sha256:0847b0035083cd439c00ff3087392f67035b26842209c110ee976f51ec13fbcd",
|
||||
"glibc": "ghcr.io/whyour/qinglong@sha256:d2775474223793a5dfe0060910acdcd9bab4c2b70ff6b2ef770ec536afb206f6"
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+16
-9
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -758,14 +758,6 @@
|
||||
"暂存更新已变化,请重新下载更新。": "暂存更新已变化,请重新下载更新。",
|
||||
"启动版本与更新包不一致": "启动版本与更新包不一致",
|
||||
"下载完成,等待重启": "下载完成,等待重启",
|
||||
"升级器已更新,请继续升级新版": "升级器已更新,请继续升级新版",
|
||||
"升级器已更新": "升级器已更新",
|
||||
"当前仍运行旧版,请继续下载并重启以完成新版升级。": "当前仍运行旧版,请继续下载并重启以完成新版升级。",
|
||||
"继续升级新版": "继续升级新版",
|
||||
"升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。": "升级器已更新。第二步将下载并预启动新版,准备成功后再重启应用。",
|
||||
"下载新版": "下载新版",
|
||||
"更新索引尚未提供第二步运行包,请稍后重试": "更新索引尚未提供第二步运行包,请稍后重试",
|
||||
"第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试": "第二步更新包仍是旧数据库后端,已保留当前服务,请稍后重试",
|
||||
"升级成功": "升级成功",
|
||||
"已重新启动并运行新版本,无需再次重启。": "已重新启动并运行新版本,无需再次重启。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? '更新已就绪,重启后应用新版本'
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user