feat: ship verified platform upgrades with channel bundle releases

Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

Publish release bundles with master images and expose an opt-in develop build
sharing the development index across Alpine and Debian images.
This commit is contained in:
whyour
2026-10-18 22:19:49 +08:00
parent a36a77086a
commit e8b6a30bd9
53 changed files with 5271 additions and 122 deletions
+115
View File
@@ -8,6 +8,9 @@ on:
# CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "cli/src/internal/maintenance/platformBundle.cjs"
- "cli/src/internal/maintenance/upgradePrestart.cjs"
- "cli/src/internal/maintenance/panelUpgrade.cjs"
- "!.github/workflows/cli-package.yml"
branches:
- "master"
@@ -16,6 +19,10 @@ on:
- "v*"
workflow_dispatch:
inputs:
build_development_bundles:
description: Develop only - build and publish shared develop/debian-dev upgrade packages
type: boolean
default: false
static_sync_only:
description: Synchronize an existing static snapshot to GitLab and verify Gitee
type: boolean
@@ -33,6 +40,14 @@ on:
permissions:
contents: read
# Keep master image tags and their bundle indexes in one serialized release.
# A rerun may intentionally replace a version, including from the same commit.
# Manual development bundle releases are serialized independently of master.
# Ordinary develop pushes and Git-tag builds remain independent.
concurrency:
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles && 'develop-bundles') || format('{0}-{1}', github.run_id, github.run_attempt) }}
cancel-in-progress: false
jobs:
validate:
if: ${{ !inputs.static_sync_only }}
@@ -203,6 +218,8 @@ jobs:
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04
permissions:
packages: write
@@ -268,6 +285,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -289,6 +307,8 @@ jobs:
build-debian:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
needs: build-static
outputs:
image-digest: ${{ steps.image.outputs.digest }}
runs-on: ubuntu-22.04
permissions:
packages: write
@@ -353,6 +373,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian)
id: image
uses: docker/build-push-action@v7
with:
build-args: |
@@ -563,3 +584,97 @@ jobs:
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
build-upgrade-bundles:
# Development packages are opt-in; both channels use this run's image digests.
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
needs: [build-alpine, build-debian]
runs-on: ubuntu-22.04
timeout-minutes: 90
permissions:
contents: read
packages: write
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/v6, libc: glibc, name: armv6-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
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build and probe the immutable platform package
env:
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
GH_TOKEN: ${{ github.token }}
BUNDLE_LIBC: ${{ matrix.libc }}
BUNDLE_PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
digest="$ALPINE_DIGEST"
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
test -n "$digest"
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then
# This amd64 helper executes a genuine Raspbian ARMv6 userspace under
# ARM1176 QEMU. It is a CI validator, not a user runtime or bundle.
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}"
mkdir -p output/validator
docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json .
validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade .
else
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
fi
- uses: actions/upload-artifact@v6
with:
name: upgrade-${{ matrix.name }}-${{ github.sha }}
path: output/upgrade/*
if-no-files-found: error
publish-upgrade-bundles:
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
needs: build-upgrade-bundles
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: upgrade-channel-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
- run: pnpm install --prod --frozen-lockfile
- run: npm --prefix cli ci
- uses: actions/download-artifact@v7
with:
pattern: upgrade-*-${{ github.sha }}
path: output/upgrade
merge-multiple: true
- run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs
- name: Publish complete packages before advancing the channel
env:
GH_TOKEN: ${{ github.token }}
QL_BUILD_DEVELOPMENT_BUNDLES: ${{ inputs.build_development_bundles }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+11 -28
View File
@@ -41,11 +41,18 @@ class Application {
}
private startMasterProcess() {
const readyTimeout =
process.arch === 'arm' &&
Number(
(process.config.variables as { arm_version?: string | number }).arm_version,
) === 6
? 120000
: 30000;
// Fork gRPC worker first and wait for it to be ready
const grpcWorker = this.forkWorker('grpc');
// Wait for gRPC worker to signal it's ready before starting HTTP worker
this.waitForWorkerReady(grpcWorker, 30000)
this.waitForWorkerReady(grpcWorker, readyTimeout)
.then(() => {
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
this.httpWorker = this.forkWorker('http');
@@ -71,7 +78,7 @@ class Application {
Logger.warn('Unable to notify HTTP worker of scheduler exit');
}
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
.then(() => {
Logger.info('gRPC worker restarted and ready');
// Re-register cron jobs by notifying the HTTP worker
@@ -148,32 +155,8 @@ class Application {
}
private async setupMiddlewares(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('./middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
const { createHttpApplication } = await import('./loaders/httpApplication');
return createHttpApplication();
}
private setupMasterShutdown() {
+112
View File
@@ -0,0 +1,112 @@
// A single, disposable backend process. Resolve startup dependencies, but never
// run appLoader: its initData/initTask hooks install dependencies and start user work.
import 'reflect-metadata';
import dns from 'dns';
import http from 'http';
async function probeUpgrade() {
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
throw new Error('Upgrade probe requires its isolated supervisor');
dns.setDefaultResultOrder('ipv4first');
const { default: config } = await import('../config');
const { sequelize } = await import('../data');
const { default: loadDatabase } = await import('../loaders/db');
const { Container } = await import('typedi');
const { GrpcServerService } = await import('../services/grpc');
const { HttpServerService } = await import('../services/http');
const { createHttpApplication } = await import('../loaders/httpApplication');
const { default: loadServer } = await import('../loaders/server');
const { default: loadExpress } = await import('../loaders/express');
await import('../loaders/app'); // Resolve normal startup hooks without running them.
const { default: inject } = await import('../loaders/depInjector');
const grpc = Container.get(GrpcServerService);
const server = Container.get(HttpServerService);
try {
await loadDatabase();
// Exercise an actual on-disk transaction as well as schema initialization.
await sequelize.transaction(async (transaction) => {
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
transaction,
});
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
transaction,
});
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
transaction,
});
if ((rows[0] as { value: number })?.value !== 42)
throw new Error('Upgrade database readback failed');
});
inject();
config.grpcPort = await grpc.initialize();
const { default: cronClient } = await import('../schedule/client');
cronClient.readiness.configure(async () => {});
if (!(await cronClient.readiness.recover()))
throw new Error('Upgrade scheduler readiness failed');
const app = await createHttpApplication();
loadExpress({ app });
const listener = await server.initialize(app, 0);
await loadServer({ server: listener });
const address = listener.address();
if (!address || typeof address === 'string')
throw new Error('No probe HTTP port');
const health = await new Promise<any>((resolve, reject) => {
const request = http.get(
`http://127.0.0.1:${address.port}/api/health`,
(response) => {
let body = '';
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', reject);
response.on('end', () => {
try {
const result = JSON.parse(body);
if (
response.statusCode !== 200 ||
result.code !== 200 ||
result.data?.status !== 'ok' ||
!result.data.services?.grpc ||
!result.data.services?.http
)
throw new Error('Upgrade HTTP/gRPC health check failed');
resolve(result.data);
} catch (error) {
reject(error);
}
});
},
);
request.setTimeout(10000, () =>
request.destroy(new Error('Upgrade health request timed out')),
);
request.on('error', reject);
});
process.send!({
type: 'upgrade-probe',
ok: true,
build: health.build,
memory: process.memoryUsage(),
pid: process.pid,
});
} finally {
await server.shutdown();
grpc.getServer().forceShutdown();
await sequelize.close();
}
}
process.once('disconnect', () => process.exit(1));
probeUpgrade()
.then(() => process.exit(0))
.catch((error) => {
process.send?.({
type: 'upgrade-probe',
ok: false,
code: error.code,
message: error.message,
stack: error.stack,
});
console.error(error);
process.exit(1);
});
+33
View File
@@ -0,0 +1,33 @@
import type express from 'express';
import config from '../config';
export async function createHttpApplication(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('../middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(
helmet({
contentSecurityPolicy: false,
}),
);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
+2 -2
View File
@@ -50,13 +50,13 @@ export class GrpcServerService {
for (const host of hostsToTry) {
try {
const address = this.formatGrpcAddress(host, grpcPort);
await bindAsync(address, credentials);
const boundPort = await bindAsync(address, credentials);
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
metricsService.record('grpc_service_start', 1, {
port: grpcPort.toString(),
host
});
return grpcPort;
return boundPort;
} catch (err) {
lastError = err as Error;
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
+76 -2
View File
@@ -7,6 +7,7 @@
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"license": "Apache-2.0",
"bin": {
"ql": "dist/npm/ql.js"
},
@@ -14,12 +15,12 @@
"@types/node": "22.19.0",
"commander": "15.0.0",
"esbuild": "0.28.2",
"tar": "7.5.20",
"typescript": "5.2.2"
},
"engines": {
"node": ">=22.12.0"
},
"license": "Apache-2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
@@ -463,6 +464,19 @@
"node": ">=18"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": {
"version": "22.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
@@ -473,6 +487,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
@@ -525,6 +549,46 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -545,6 +609,16 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
}
}
}
+2 -1
View File
@@ -26,7 +26,8 @@
"commander": "15.0.0",
"esbuild": "0.28.2",
"typescript": "5.2.2",
"@types/node": "22.19.0"
"@types/node": "22.19.0",
"tar": "7.5.20"
},
"license": "Apache-2.0",
"repository": {
+15
View File
@@ -17,6 +17,21 @@ fs.copyFileSync(
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
);
fs.copyFileSync(
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
);
// The operational CLI may live outside the panel and has no panel node_modules.
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
minify: true,
legalComments: 'inline',
});
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
outfile: path.join(root, 'dist/shared/cli/commander.js'),
+8
View File
@@ -150,6 +150,14 @@ export async function startPanel(
GRPC_PORT: context.env.QlGrpcPort || '5500',
},
};
// Archive selection and launcher ownership apply only to the upgrade worker.
// Retaining them in PM2 would pin the next online update to an offline file.
for (const name of [
'QL_UPGRADE_FILE',
'QL_UPGRADE_REPAIR_DEPS',
'QL_UPGRADE_LAUNCHER_PID',
])
delete options.env[name as keyof typeof options.env];
if (await executableAvailable('pm2', context.env)) {
const flushed = await runProcess('pm2', ['flush'], options);
if (flushed.code === 0) {
+112 -8
View File
@@ -6,6 +6,7 @@ const { spawn } = require('node:child_process');
const http = require('node:http');
const { setTimeout: delay } = require('node:timers/promises');
const artifacts = require('./upgradeArtifacts.cjs');
const { nodeRuntimeArgs } = require('./platformBundle.cjs');
const active = new Set(['starting', 'running', 'restarting']);
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
@@ -200,6 +201,93 @@ async function getStatus(tmp) {
return state;
}
// Manual restarts accept the currently prepared package, including one retained
// after a failed preparation. Legacy installs without jobs keep their old path;
// complete-bundle installs always verify and record the actual running build.
async function withPreparedJob(tmp, env, selected, operation) {
const previous = await getStatus(tmp);
if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。');
const hasRecord = await fs
.access(stateFile(tmp))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
if (!hasRecord) {
const pkg = env.QL_DIR
? JSON.parse(
await fs
.readFile(path.join(env.QL_DIR, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
)
: {};
if (pkg.qinglongUpgradeFormat !== 2) return operation();
}
return artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
async () => {
const current = await getStatus(tmp);
if (active.has(current.status))
throw new Error('已有更新或重载正在执行。');
const prepared = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
if (
prepared.source !== selected.source ||
prepared.static !== selected.static
)
throw new Error('暂存更新已变化,请重新下载更新。');
const metadata = await stagedMetadata(selected);
const previousBuild = await readRunningBuild(env).catch(() => undefined);
const { log, error, ...record } = current;
let state = {
...record,
...metadata,
id: randomUUID(),
action: 'apply',
status: 'running',
phase: 'verifying',
startedAt: Date.now(),
pid: process.pid,
};
const report = async (patch) => {
state = { ...state, ...patch, updatedAt: Date.now() };
await save(tmp, state);
};
await fs.writeFile(
logFile(tmp),
'Manual application of prepared update\n',
{
mode: 0o600,
},
);
await report({});
try {
const result = await operation({
onStop: () => report({ status: 'restarting', phase: 'restarting' }),
onStart: (recover) =>
recover
? undefined
: verifyRunningVersion(env, state, previousBuild),
});
await report({ status: 'completed', phase: 'completed' });
return result;
} catch (error) {
await report({ status: 'failed', error: error.message });
throw error;
}
},
env,
);
}
async function startJob({ root, tmp, env = process.env }, action) {
if (!['download', 'apply'].includes(action))
throw new Error('Invalid upgrade action');
@@ -220,12 +308,14 @@ async function startJob({ root, tmp, env = process.env }, action) {
let selected;
let metadata;
if (action === 'apply') {
// Fail before launching a process or stopping the working panel.
// Keep requests short on slow devices. The detached worker performs
// complete file/dependency verification before it can stop the panel.
try {
selected = await artifacts.selectedUpgrade(
tmp,
artifacts.releaseBranch(env),
env,
{ metadataOnly: true },
);
metadata = await stagedMetadata(selected);
if (previous.status === 'ready')
@@ -390,7 +480,7 @@ async function runJob({ root, tmp, env = process.env }, id) {
);
await artifacts.waitForHealth(
env,
60000,
undefined,
recover ? undefined : controller.signal,
);
if (!recover)
@@ -429,16 +519,30 @@ async function runJob({ root, tmp, env = process.env }, id) {
}
}
module.exports = { getStatus, startJob, runJob };
module.exports = {
getStatus,
saveState: save,
startJob,
runJob,
withPanelLifecycle,
readRunningBuild,
verifyRunningVersion,
stagedMetadata,
withPreparedJob,
};
async function launchWorker(id) {
// detached creates a session but keeps the original parent. PM2 kills the
// backend's entire descendant tree, so an intermediate launcher must exit
// before the worker is allowed to stop that backend.
const child = spawn(process.execPath, [__filename, 'worker', id], {
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
detached: true,
stdio: 'inherit',
});
const child = spawn(
process.execPath,
[...nodeRuntimeArgs(), __filename, 'worker', id],
{
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
detached: true,
stdio: 'inherit',
},
);
await new Promise((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
@@ -0,0 +1,579 @@
// One installed verifier handles release downloads and local offline archives.
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const os = require('node:os');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const tar = require('tar');
const {
prestartBackend,
dependencyFailure,
repairDependencies,
} = require('./upgradePrestart.cjs');
const execute = promisify(execFile);
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
// upgrade workers interpreted without changing the installed Node or server.
const nodeRuntimeArgs = (runtime = process) =>
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
? ['--jitless']
: [];
const sha256 = (bytes) =>
crypto.createHash('sha256').update(bytes).digest('hex');
const inside = (root, filename) => {
const relative = path.relative(root, filename);
return (
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
};
const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number);
function atLeast(actual, minimum) {
const a = versionParts(actual),
b = versionParts(minimum);
return (
b.every((n) => Number.isFinite(n)) &&
a.every((n) => Number.isFinite(n)) &&
(a[0] > b[0] ||
(a[0] === b[0] &&
(a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0)))))
);
}
async function runtimeTarget() {
let libc, libcVersion;
if (process.platform === 'linux') {
const header = process.report.getReport().header;
if (header.glibcVersionRuntime) {
libc = 'glibc';
libcVersion = header.glibcVersionRuntime;
} else {
let text = '';
try {
text = (await execute('ldd', ['--version'])).stdout;
} catch (error) {
text = `${error.stdout || ''}\n${error.stderr || ''}`;
}
const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i);
if (!match)
throw new Error(
'Cannot identify Linux libc; refusing a native dependency bundle',
);
libc = 'musl';
libcVersion = match[1];
}
}
const armVersion =
process.arch === 'arm'
? Number(process.config.variables.arm_version)
: undefined;
if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion))
throw new Error('Cannot identify ARM instruction set');
const arch =
process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch;
const armFloatAbi =
process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined;
if (
process.arch === 'arm' &&
!['hard', 'soft', 'softfp'].includes(armFloatAbi)
)
throw new Error('Cannot identify ARM floating-point ABI');
return {
platform: process.platform,
arch,
libc,
libcVersion,
endianness: os.endianness(),
armFloatAbi,
};
}
function targetKey(target) {
return [target.platform, target.arch, target.libc].filter(Boolean).join('-');
}
function assertCompatible(manifest, target, node = process.versions.node) {
if (
manifest.format !== 2 ||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
!manifest.files ||
typeof manifest.files !== 'object' ||
Array.isArray(manifest.files) ||
!Number.isSafeInteger(manifest.nodeApi) ||
manifest.nodeApi < 6
)
throw new Error('Invalid platform bundle manifest');
if (targetKey(manifest.target || {}) !== targetKey(target))
throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`);
if (
!atLeast(node, manifest.nodeMinimum) ||
Number(process.versions.napi) < manifest.nodeApi
)
throw new Error(
`Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`,
);
if (
manifest.target.endianness !== target.endianness ||
manifest.target.armFloatAbi !== target.armFloatAbi
)
throw new Error(
'Bundle native byte order or ARM floating-point ABI mismatch',
);
if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum))
throw new Error(
`Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`,
);
}
async function inventory(roots) {
const files = {};
async function visit(directory, prefix) {
for (const name of (await fs.readdir(directory)).sort()) {
const filename = path.join(directory, name),
relative = `${prefix}/${name}`;
const stat = await fs.lstat(filename);
if (stat.isDirectory()) await visit(filename, relative);
else if (stat.isFile())
files[relative] = {
type: 'file',
mode: stat.mode & 0o777,
sha256: sha256(await fs.readFile(filename)),
};
else if (stat.isSymbolicLink()) {
const link = await fs.readlink(filename);
const boundary = roots[prefix.split('/')[0]];
if (
path.isAbsolute(link) ||
!inside(boundary, path.resolve(path.dirname(filename), link)) ||
!inside(await fs.realpath(boundary), await fs.realpath(filename))
)
throw new Error(`Bundle link escapes its payload: ${relative}`);
files[relative] = { type: 'link', link };
} else throw new Error(`Unsupported bundle entry: ${relative}`);
}
}
for (const [name, root] of Object.entries(roots)) await visit(root, name);
return files;
}
async function verifyPayload(selected, manifest, target) {
assertCompatible(manifest, target);
for (const name of ['data', '.tmp', '.git', '.env', 'static']) {
if (
await fs.lstat(path.join(selected.source, name)).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return null;
})
)
throw new Error(`Reserved installation path in bundle: ${name}`);
}
const actual = await inventory({
source: selected.source,
static: selected.static,
});
const names = Object.keys(actual).sort(),
expected = Object.keys(manifest.files).sort();
if (
JSON.stringify(names) !== JSON.stringify(expected) ||
names.some(
(name) =>
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
)
)
throw new Error(
'Platform bundle files or dependency checksums do not match',
);
if (
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
manifest.lockfileSha256
)
throw new Error('Platform bundle lockfile mismatch');
await fs.access(
path.join(selected.source, 'node_modules/sqlite3/package.json'),
);
}
async function smokeDependencies(source, signal) {
// This probe is installed verifier code, never a verifier supplied by an archive.
const probe = `
const path = require('node:path'),fs = require('node:fs');
const root = fs.realpathSync(process.cwd());
const r = require('node:module').createRequire(path.join(root,'package.json'));
for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) {
if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name);
r(name);
}
const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:');
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
`;
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
// WebAssembly, which --jitless disables; only archive workers need that flag.
await execute(process.execPath, ['-e', probe], {
cwd: source,
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
timeout: 30000,
signal,
});
}
async function extractArchive(archive, destination) {
const entries = new Map(),
links = new Set();
let unpacked = 0,
invalid;
await tar.t({
file: archive,
strict: true,
onReadEntry(entry) {
try {
const name = entry.path.replace(/\/$/, '');
if (
!name ||
name.includes('\\') ||
/[\x00-\x1f\x7f]/.test(name) ||
name.split('/').some((p) => !p || p === '.' || p === '..') ||
!(name === 'qinglong' || name.startsWith('qinglong/')) ||
entries.has(name) ||
!['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type)
)
throw new Error(`Unsafe bundle archive entry: ${name}`);
unpacked += entry.size;
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
throw new Error('Bundle archive exceeds extraction limits');
entries.set(name, entry.type);
if (entry.type === 'SymbolicLink') {
if (
path.posix.isAbsolute(entry.linkpath) ||
entry.linkpath.includes('\\') ||
!inside(
'/qinglong',
path.posix.resolve('/', path.posix.dirname(name), entry.linkpath),
)
)
throw new Error('Unsafe bundle symlink');
links.add(name);
}
} catch (error) {
invalid ||= error;
}
},
});
if (invalid) throw invalid;
for (const name of entries.keys()) {
let parent = path.posix.dirname(name);
while (parent !== '.') {
if (links.has(parent))
throw new Error('Archive writes through a symlink');
parent = path.posix.dirname(parent);
}
}
await tar.x({
file: archive,
cwd: destination,
strict: true,
preservePaths: false,
noChown: true,
umask: 0,
});
}
async function stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic,
download,
probe = smokeDependencies,
prestart = prestartBackend,
repair = repairDependencies,
}) {
let local = env.QL_UPGRADE_FILE;
if (!local) {
const current = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
}
const target = await runtimeTarget();
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
? 'develop'
: 'master';
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const report = onProgress || (async () => {});
try {
const archive = path.join(workspace, 'bundle.tar.gz');
let expectedCommit;
if (local) {
if (!path.isAbsolute(local))
throw new Error('Offline bundle path must be absolute');
// Snapshot the local input so replacing it cannot race validation/extraction.
await fs.copyFile(local, archive);
} else {
const indexUrl =
env.QL_UPGRADE_INDEX_URL ||
`https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`;
const checkUrl = (url) => {
const parsed = new URL(url);
if (
parsed.protocol !== 'https:' &&
!(
parsed.protocol === 'http:' &&
['127.0.0.1', '[::1]'].includes(parsed.hostname)
)
)
throw new Error('Upgrade URLs require HTTPS');
};
checkUrl(indexUrl);
const indexFile = path.join(workspace, 'index.json');
await run('curl', [
'--fail',
'--silent',
'--show-error',
'--location',
'--proto',
new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https',
'--proto-redir',
'=https',
'--header',
'Cache-Control: no-cache',
'--connect-timeout',
'10',
'--max-time',
'30',
'--max-filesize',
'1048576',
'--output',
indexFile,
indexUrl,
]);
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
const entry = index.packages?.[targetKey(target)];
if (
index.format !== 2 ||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
!entry ||
!Number.isSafeInteger(entry.size) ||
entry.size <= 0 ||
entry.size > 512 * 1024 ** 2 ||
!/^[a-f0-9]{64}$/.test(entry.sha256)
)
throw new Error(`No verified update bundle for ${targetKey(target)}`);
checkUrl(entry.url);
expectedCommit = index.sourceCommit;
await report({
phase: 'downloading',
artifact: 'qinglong-bundle',
downloadedBytes: 0,
totalBytes: entry.size,
percent: 0,
});
await download(
run,
archive,
entry.url,
'qinglong-bundle',
report,
true,
entry,
);
}
if ((await fs.stat(archive)).size > 512 * 1024 ** 2)
throw new Error('Bundle is too large');
await report({
phase: 'extracting',
artifact: 'qinglong-bundle',
percent: null,
});
await extractArchive(archive, workspace);
const incoming = path.join(workspace, 'qinglong');
const manifest = JSON.parse(
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
);
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
throw new Error('Update index and bundle commit differ');
const selected = {
source: path.join(workspace, `qinglong-${branch}`),
static: path.join(workspace, `qinglong-static-${branch}`),
};
await fs.rename(path.join(incoming, 'source'), selected.source);
await fs.rename(path.join(incoming, 'static'), selected.static);
await report({ phase: 'verifying', percent: null });
await verifyPayload(selected, manifest, target);
const build = await verifyStatic(selected.source, selected.static, env);
if (build.sourceCommit !== manifest.sourceCommit)
throw new Error('Bundle and build commit differ');
await report({ phase: 'dependencies', percent: null });
let dependencyFiles;
const testCandidate = async () => {
await probe(selected.source, signal);
await report({ phase: 'prestarting', percent: null });
return prestart(selected, { target, signal });
};
try {
await testCandidate();
} catch (error) {
signal?.throwIfAborted();
if (!dependencyFailure(error)) throw error;
if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1')
throw new Error(
`Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`,
);
await report({ phase: 'repairing', percent: null });
await repair(selected.source, { target, signal, env });
const repaired = await inventory({
source: selected.source,
static: selected.static,
});
dependencyFiles = Object.fromEntries(
Object.entries(repaired).filter(([name]) =>
name.startsWith('source/node_modules/'),
),
);
// A repair may change only dependencies, never bless changed application code.
await verifyPayload(
selected,
manifestWithDependencies(manifest, dependencyFiles),
target,
);
await verifyStatic(selected.source, selected.static, env);
await testCandidate(); // Exactly one repair; this failure propagates.
}
await fs.writeFile(
path.join(workspace, 'prestart.json'),
JSON.stringify({
format: 1,
sourceCommit: manifest.sourceCommit,
manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))),
node: process.version,
execPath: process.execPath,
completedAt: Date.now(),
...(dependencyFiles ? { dependencyFiles } : {}),
}),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'bundle.json'),
JSON.stringify(manifest),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root: path.resolve(root),
sourceCommit: manifest.sourceCommit,
dependenciesChanged: true,
bundle: true,
}),
{ flag: 'wx', mode: 0o600 },
);
const pointer = path.join(workspace, 'pointer.json');
await fs.writeFile(
pointer,
JSON.stringify({ directory: path.basename(workspace) }),
{ flag: 'wx', mode: 0o600 },
);
signal?.throwIfAborted();
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
const previous = await fs
.readFile(readyPointer, 'utf8')
.catch(() => 'null');
await fs.rename(pointer, readyPointer);
try {
const old = JSON.parse(previous)?.directory;
if (
/^upgrade-[A-Za-z0-9_-]+$/.test(old) &&
old !== path.basename(workspace)
)
await fs.rm(path.join(tmp, old), { recursive: true, force: true });
} catch {
/* Committed readiness must survive cleanup failure. */
}
return selected;
} catch (error) {
await fs.rm(workspace, { recursive: true, force: true });
throw error;
}
}
function manifestWithDependencies(manifest, dependencyFiles) {
if (!dependencyFiles) return manifest;
if (
typeof dependencyFiles !== 'object' ||
Array.isArray(dependencyFiles) ||
!Object.keys(dependencyFiles).length ||
Object.keys(dependencyFiles).some(
(name) => !name.startsWith('source/node_modules/'),
)
)
throw new Error('Invalid repaired dependency inventory');
return {
...manifest,
files: {
...Object.fromEntries(
Object.entries(manifest.files).filter(
([name]) => !name.startsWith('source/node_modules/'),
),
),
...dependencyFiles,
},
};
}
async function verifyStagedBundle(selected) {
const manifest = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'bundle.json'),
'utf8',
),
);
const prestart = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'prestart.json'),
'utf8',
),
);
if (
prestart.format !== 1 ||
prestart.sourceCommit !== manifest.sourceCommit ||
prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) ||
prestart.node !== process.version ||
prestart.execPath !== process.execPath ||
!Number.isSafeInteger(prestart.completedAt)
)
throw new Error(
'Upgrade prestart receipt is missing or stale; prepare the update again',
);
await verifyPayload(
selected,
manifestWithDependencies(manifest, prestart.dependencyFiles),
await runtimeTarget(),
);
await smokeDependencies(selected.source);
}
module.exports = {
nodeRuntimeArgs,
atLeast,
runtimeTarget,
targetKey,
assertCompatible,
inventory,
verifyPayload,
smokeDependencies,
extractArchive,
stageBundle,
verifyStagedBundle,
sha256,
};
+27 -10
View File
@@ -107,7 +107,7 @@ async function selectedUpgrade(
export interface ReloadLifecycle {
stop(context: LocalContext): Promise<void>;
start(context: LocalContext): Promise<unknown>;
start(context: LocalContext, recover?: boolean): Promise<unknown>;
}
// All old entries stay on the same filesystem until service start succeeds.
@@ -202,7 +202,7 @@ export async function replaceAndReload(
if (item.existed) await fs.rename(item.backup, item.target);
}
try {
await lifecycle.start(context);
await lifecycle.start(context, true);
} catch {
throw new Error(
translate(
@@ -300,14 +300,31 @@ export async function reloadPanel(
return await artifacts.withUpgradeLock(
context.paths.dir_tmp!,
() =>
replaceAndReload(context, replacements, {
stop: stopPanel,
start: async (ctx) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
return service;
},
}),
require('./panelUpgrade.cjs').withPreparedJob(
context.paths.dir_tmp!,
context.env,
selected,
(job?: {
onStop(): Promise<void>;
onStart(recover?: boolean): Promise<void> | undefined;
}) =>
replaceAndReload(context, replacements, {
stop: async (ctx) => {
await job?.onStop();
await stopPanel(ctx);
},
start: async (ctx, recover) => {
const service = await startPanel(ctx);
await artifacts.waitForHealth(
ctx.env,
undefined,
operationSignal(),
);
await job?.onStart(recover);
return service;
},
}),
),
context.env,
);
} finally {
+110 -18
View File
@@ -218,13 +218,19 @@ async function downloadWithProgress(
'--location',
...(quiet ? ['--silent', '--show-error'] : []),
'--proto',
'=https',
/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
? '=http,https'
: '=https',
'--proto-redir',
'=https',
'--connect-timeout',
'30',
'--max-time',
'600',
String(
artifact === 'qinglong-bundle' && expected?.size
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
: 600,
),
'--speed-limit',
'1024',
'--speed-time',
@@ -260,6 +266,26 @@ async function stageUpgrade({
signal,
onProgress,
}) {
const currentPackage = JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) {
return require('./platformBundle.cjs').stageBundle({
root,
tmp,
env,
run,
signal,
onProgress,
verifyStatic: verifyUpgrade,
download: downloadWithProgress,
});
}
const report = onProgress || (async () => {});
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
const branch = releaseBranch(env);
@@ -469,7 +495,12 @@ async function stageUpgrade({
}
}
async function selectedUpgrade(tmp, branch, env = process.env) {
async function selectedUpgrade(
tmp,
branch,
env = process.env,
{ metadataOnly = false } = {},
) {
const pointer = JSON.parse(
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
);
@@ -493,9 +524,13 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
);
if (ready.source !== selected.source || ready.static !== selected.static)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
const manifest = await verifyUpgrade(selected.source, selected.static, env);
const manifest = metadataOnly
? await manifestAt(selected.static, env)
: await verifyUpgrade(selected.source, selected.static, env);
if (ready.sourceCommit !== manifest.sourceCommit)
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
if (ready.bundle && !metadataOnly)
await require('./platformBundle.cjs').verifyStagedBundle(selected);
if (ready.dependenciesChanged)
await fs.access(path.join(selected.source, 'node_modules'));
else {
@@ -571,7 +606,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) {
} catch (error) {
// Do not modify files under a still-running failed startup. Keep backups
// for recovery if stopping the new process itself fails.
if (attemptedStart) await lifecycle.stop();
if (attemptedStart) await lifecycle.stop(true);
for (const item of changed.reverse()) {
await fs.rm(item.target, { recursive: true, force: true });
if (item.existed) {
@@ -640,7 +675,16 @@ async function reloadSystem({
.catch(() => {});
}
async function waitForHealth(env, timeout = 60000, signal) {
function startupHealthTimeout(runtime = process) {
// ARMv6 starts the database, scheduler and HTTP workers sequentially. Give
// slow devices time to become healthy before deciding to restore a backup.
return runtime.arch === 'arm' &&
Number(runtime.config.variables.arm_version) === 6
? 300000
: 60000;
}
async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
const port = Number(env.QlPort || 5700);
let base = env.QlBaseUrl || '';
if (base && !base.startsWith('/')) base = `/${base}`;
@@ -786,9 +830,18 @@ esac
mode: 0o600,
},
);
// Offline selection and worker ownership belong to this operation only.
// Passing them to PM2 would pin future online updates to the old local file.
const lifecycleEnv = { ...env };
for (const name of [
'QL_UPGRADE_FILE',
'QL_UPGRADE_REPAIR_DEPS',
'QL_UPGRADE_LAUNCHER_PID',
])
delete lifecycleEnv[name];
const hook = (operation, args = [], signal) =>
runProcess(
env,
lifecycleEnv,
'bash',
['--', snapshot, operation, ...args],
false,
@@ -821,16 +874,21 @@ async function main() {
tmp,
() =>
withLifecycleHooks(root, tmp, env, async (hook) => {
let job;
const lifecycle = {
signal: controller.signal,
stop: () => hook('stop'),
stop: async () => {
await job?.onStop();
await hook('stop');
},
start: async (recover) => {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
env,
60000,
undefined,
recover ? undefined : controller.signal,
);
await job?.onStart(recover);
},
};
if (process.argv[2] === 'update') {
@@ -850,15 +908,48 @@ async function main() {
if (process.argv[4] !== 'true') return;
} else if (process.argv[2] !== 'reload')
throw new Error('Unknown upgrade command.');
await reloadSystem({
root,
tmp,
env,
data: env.dir_data || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config: env.dir_config || path.join(root, 'data/config'),
lifecycle,
});
const apply = async (preparedJob) => {
job = preparedJob;
await reloadSystem({
root,
tmp,
env,
data: env.dir_data || path.join(root, 'data'),
staticRoot: env.dir_static || path.join(root, 'static'),
config: env.dir_config || path.join(root, 'data/config'),
lifecycle,
});
};
// Legacy reloads can carry only this self-contained Bash verifier.
// Complete-bundle installs also track manual applies without a job file.
const hasPanelJob = await fs
.access(path.join(tmp, 'panel-upgrade.json'))
.then(() => true)
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return false;
});
const packageInfo = hasPanelJob
? {}
: JSON.parse(
await fs
.readFile(path.join(root, 'package.json'), 'utf8')
.catch((error) => {
if (error.code !== 'ENOENT') throw error;
return '{}';
}),
);
if (hasPanelJob || packageInfo.qinglongUpgradeFormat === 2) {
const selected = await selectedUpgrade(tmp, releaseBranch(env), env, {
metadataOnly: true,
});
await require('./panelUpgrade.cjs').withPreparedJob(
tmp,
env,
selected,
apply,
);
} else await apply();
console.log(upgradeMessage('更新及启动检查成功...', env));
}),
env,
@@ -875,6 +966,7 @@ module.exports = {
selectedUpgrade,
replaceAndReload,
reloadSystem,
startupHealthTimeout,
waitForHealth,
runProcess,
withLifecycleHooks,
@@ -0,0 +1,393 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const crypto = require('node:crypto');
const os = require('node:os');
function probeLimits(target) {
const small = target.arch === 'armv6';
return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 };
}
function dependencyFailure(error) {
const text = `${error.message || ''}\n${error.stack || ''}\n${
error.stderr || ''
}`;
if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text))
return false;
if (
/ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) &&
/node_modules[/\\]/.test(text)
)
return true;
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
text,
)?.[1];
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1];
const inDependencies = (filename) =>
/(?:^|[/\\])node_modules[/\\]/.test(filename || '');
// Node reports a missing package entry as an absolute path and missing
// package-internal imports as relative paths. Neither is an app source error.
const dependencyMissing =
missing &&
((!missing.startsWith('.') &&
!path.isAbsolute(missing) &&
!missing.startsWith('node:')) ||
inDependencies(missing) ||
inDependencies(importer));
// Missing backend files, bad config and database errors must never trigger npm.
return (
(/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) &&
Boolean(dependencyMissing)) ||
(/Could not locate the bindings file/.test(text) &&
/node_modules[/\\]/.test(text))
);
}
async function prestartBackend(
selected,
{ target, signal, limits = probeLimits(target) },
) {
signal?.throwIfAborted();
const workspace = await fs.mkdtemp(
path.join(path.dirname(selected.source), 'prestart-'),
);
try {
const data = path.join(workspace, 'data');
for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload'])
await fs.mkdir(path.join(data, name), { recursive: true });
// Copy only backend code/metadata, never all frontend assets or node_modules.
await fs.mkdir(path.join(workspace, 'static'));
await fs.cp(
path.join(selected.static, 'build'),
path.join(workspace, 'static/build'),
{ recursive: true },
);
await fs.copyFile(
path.join(selected.static, 'build-info.json'),
path.join(workspace, 'static/build-info.json'),
);
await fs.copyFile(
path.join(selected.source, 'version.yaml'),
path.join(workspace, 'version.yaml'),
);
await fs.symlink(
path.join(selected.source, 'node_modules'),
path.join(workspace, 'node_modules'),
'dir',
);
await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 });
const entry = path.join(
workspace,
'static/build/bootstrap/upgradeProbe.js',
);
await fs.access(entry);
const expected = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json')),
);
const backendSha256 = crypto
.createHash('sha256')
.update(await fs.readFile(path.join(selected.static, 'build/app.js')))
.digest('hex');
const version = (
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
// package-manager settings from escaping into the disposable backend.
const env = {
PATH: process.env.PATH,
LANG: process.env.LANG || 'C.UTF-8',
HOME: workspace,
TMPDIR: workspace,
NODE_ENV: 'production',
QL_DIR: workspace,
QL_DATA_DIR: data,
QL_UPGRADE_PROBE: '1',
BACK_PORT: '0',
GRPC_PORT: '0',
BIND_HOST: '127.0.0.1',
BIND_HOST_GRPC: '127.0.0.1',
QlBaseUrl: '/',
NODE_PATH: '',
NODE_OPTIONS: '',
UV_THREADPOOL_SIZE: '1',
};
return await new Promise((resolve, reject) => {
let report,
failure,
output = '',
finished = false,
killTimer;
// No PM2/cluster workers. OpenSSL key generation is sequential in this
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
const child = spawn(
process.execPath,
[`--max-old-space-size=${limits.heapMb}`, entry],
{
cwd: workspace,
env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
},
);
try {
if (child.pid) os.setPriority(child.pid, 10);
} catch {}
const kill = (signalName) => {
try {
if (process.platform !== 'win32' && child.pid)
process.kill(-child.pid, signalName);
else child.kill(signalName);
} catch (error) {
if (error.code !== 'ESRCH') failure ||= error;
}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(signal.reason || new Error('Upgrade prestart aborted'));
const timer = setTimeout(
() => stop(new Error('Upgrade prestart timed out')),
limits.timeoutMs,
);
child.stdout.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.stderr.on('data', (chunk) => {
output = (output + chunk).slice(-32768);
});
child.on('message', (message) => {
if (message?.type === 'upgrade-probe') report = message;
});
const finish = (error) => {
if (finished) return;
finished = true;
clearTimeout(timer);
clearTimeout(killTimer);
signal?.removeEventListener('abort', abort);
if (error) reject(error);
else resolve(report);
};
child.on('error', (error) => finish(error));
// Descendants can retain our output pipes after their parent exits.
// Reap the group on exit rather than waiting for those pipes to close.
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, exitSignal) => {
// Reap descendants even when the backend exits unexpectedly.
kill('SIGKILL');
if (failure) return finish(failure);
if (code !== 0 || !report?.ok)
return finish(
Object.assign(
new Error(
report?.message ||
`Upgrade prestart exited: ${exitSignal || code}\n${output}`,
),
{
code: report?.code,
stack: report?.stack || output,
signal: exitSignal,
},
),
);
if (
report.build?.sourceCommit !== expected.sourceCommit ||
report.build?.backendSha256 !== backendSha256 ||
report.build?.version !== version
)
return finish(new Error('Upgrade prestart build identity mismatch'));
finish();
});
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
async function repairDependencies(source, { target, signal, env }) {
const workspace = await fs.mkdtemp(
path.join(path.dirname(source), 'repair-'),
);
try {
const pkg = JSON.parse(
await fs.readFile(path.join(source, 'package.json')),
);
if (pkg.packageManager !== 'pnpm@8.3.1')
throw new Error('Unsupported frozen-lockfile package manager');
// Install apart from the verified application: lifecycle scripts cannot
// rewrite source/static files or use the old installation's node_modules.
await fs.writeFile(
path.join(workspace, 'package.json'),
JSON.stringify({ ...pkg, scripts: {} }),
);
await fs.copyFile(
path.join(source, 'pnpm-lock.yaml'),
path.join(workspace, 'pnpm-lock.yaml'),
);
const small = target.arch === 'armv6';
const childEnv = {
PATH: env.PATH || process.env.PATH,
HOME: workspace,
TMPDIR: workspace,
NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${
small ? 256 : 384
}`,
NODE_PATH: '',
CI: 'true',
npm_config_jobs: '1',
MAKEFLAGS: '-j1',
...(small
? {
npm_config_build_from_source: 'true',
npm_config_force_process_config: 'true',
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
}
: {}),
};
// Preserve network routing without inheriting Node/preload/compiler settings.
for (const name of [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
'no_proxy',
])
if (typeof env[name] === 'string') childEnv[name] = env[name];
// Reuse matching installed headers on small devices; never inherit a
// caller's nodedir or accidentally compile against a different Node ABI.
const prefix = path.resolve(path.dirname(process.execPath), '..');
const header = await fs
.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8')
.catch(() => '');
const headerVersion = ['MAJOR', 'MINOR', 'PATCH']
.map(
(part) =>
header.match(
new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`),
)?.[1],
)
.join('.');
if (headerVersion === process.versions.node) {
childEnv.npm_config_nodedir = prefix;
childEnv.npm_config_force_process_config = 'true';
}
const run = (args) =>
runRepair('pnpm', args, {
cwd: workspace,
env: childEnv,
signal,
timeoutMs: small ? 1800000 : 600000,
});
const version = await run(['--version']);
if (version.trim() !== '8.3.1')
throw new Error('Dependency repair requires existing pnpm 8.3.1');
await run([
'install',
'--prod',
'--frozen-lockfile',
'--ignore-scripts',
'--child-concurrency=1',
'--network-concurrency=2',
]);
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
if (
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
)
)
throw new Error('Dependency repair changed the frozen lockfile');
await fs.rm(path.join(source, 'node_modules'), {
recursive: true,
force: true,
});
await fs.rename(
path.join(workspace, 'node_modules'),
path.join(source, 'node_modules'),
);
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
// Bound output, wall time and the entire npm/node-gyp process group.
async function runRepair(program, args, options) {
return new Promise((resolve, reject) => {
options.signal?.throwIfAborted();
let output = '',
stdout = '',
failure,
killTimer;
const child = spawn(program, args, {
cwd: options.cwd,
env: options.env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
});
const kill = (sig) => {
try {
if (child.pid && process.platform !== 'win32')
process.kill(-child.pid, sig);
else child.kill(sig);
} catch {}
};
const stop = (error) => {
failure ||= error;
kill('SIGTERM');
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
};
const abort = () =>
stop(options.signal.reason || new Error('Dependency repair aborted'));
const timer = setTimeout(
() => stop(new Error('Dependency repair timed out')),
options.timeoutMs,
);
const capture = (chunk) => {
output = (output + chunk).slice(-32768);
};
child.stdout.on('data', (chunk) => {
stdout = (stdout + chunk).slice(-32768);
capture(chunk);
});
child.stderr.on('data', capture);
const cleanup = () => {
clearTimeout(timer);
clearTimeout(killTimer);
options.signal?.removeEventListener('abort', abort);
kill('SIGKILL');
};
child.on('error', (error) => {
cleanup();
reject(error);
});
child.once('exit', () => kill('SIGKILL'));
child.on('close', (code, signal) => {
cleanup();
if (failure || code !== 0)
reject(
failure ||
new Error(
`Dependency repair failed (${signal || code}): ${output}`,
),
);
else resolve(stdout);
});
options.signal?.addEventListener('abort', abort, { once: true });
if (options.signal?.aborted) abort();
});
}
module.exports = {
prestartBackend,
dependencyFailure,
repairDependencies,
probeLimits,
};
+11 -2
View File
@@ -322,6 +322,10 @@ test(
async (t) => {
if (process.platform === 'win32') return t.skip('POSIX process groups');
const { runProcess } = require('../../dist/internal/runtime/process');
const marker = path.join(sandbox(t), 'timeout');
const controller = new AbortController();
const guard = setTimeout(() => controller.abort(), 4000);
t.after(() => clearTimeout(guard));
let descendant;
t.after(() => {
if (descendant)
@@ -329,13 +333,17 @@ test(
process.kill(descendant, 'SIGKILL');
} catch {}
});
// Start the deadline only after the descendant installed its handlers.
// Cold Node startup under parallel load need not fit in 350 milliseconds.
const grandchild =
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)';
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)';
const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
grandchild,
)}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),100);`;
)}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`;
const result = await runProcess(process.execPath, ['-e', parent], {
timeoutMs: 350,
timeoutControl: { marker },
signal: controller.signal,
graceMs: 100,
output: (chunk) => {
const pid = Number(chunk.toString().trim());
@@ -345,6 +353,7 @@ test(
assert.equal(result.code, 124);
assert.equal(result.timedOut, true);
assert.ok(descendant);
assert.equal(fs.existsSync(marker), true);
},
);
+34
View File
@@ -20,6 +20,40 @@ const {
sourceCommit,
} = require('../../../test/helpers/upgrade-fixture.cjs');
test('CLI service startup drops operation-only upgrade variables and preserves user configuration', async (t) => {
const ctx = await fixture(t);
ctx.env.CALLS = path.join(ctx.root, 'runtime.jsonl');
ctx.env.QL_UPGRADE_FILE = '/old/offline.tar.gz';
ctx.env.QL_UPGRADE_REPAIR_DEPS = '1';
ctx.env.QL_UPGRADE_LAUNCHER_PID = '12345';
ctx.env.QA_USER_SETTING = 'preserved';
ctx.env.HTTPS_PROXY = 'http://127.0.0.1:7890';
await stub(
ctx,
'pm2',
`require('node:fs').appendFileSync(process.env.CALLS,JSON.stringify({
file:process.env.QL_UPGRADE_FILE??null,repair:process.env.QL_UPGRADE_REPAIR_DEPS??null,
launcher:process.env.QL_UPGRADE_LAUNCHER_PID??null,user:process.env.QA_USER_SETTING,
proxy:process.env.HTTPS_PROXY,
})+'\\n');`,
);
await startPanel(ctx);
for (const runtime of (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse))
assert.deepEqual(runtime, {
file: null,
repair: null,
launcher: null,
user: 'preserved',
proxy: 'http://127.0.0.1:7890',
});
assert.equal(ctx.env.QL_UPGRADE_FILE, '/old/offline.tar.gz');
assert.equal(ctx.env.QL_UPGRADE_REPAIR_DEPS, '1');
assert.equal(ctx.env.QL_UPGRADE_LAUNCHER_PID, '12345');
});
async function fixture(t) {
t.mock.method(
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
+35
View File
@@ -0,0 +1,35 @@
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 { spawnSync } = require('node:child_process');
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filename = path.join(directory, 'platformBundle.cjs');
fs.copyFileSync(
path.resolve(
__dirname,
'../../dist/internal/maintenance/platformBundle.cjs',
),
filename,
);
const code = `
const Module=require('node:module'),load=Module._load;
Module._load=function(name,...args){
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
return load.call(this,name,...args);
};
const bundle=require(process.argv[1]);
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
`;
const result = spawnSync(process.execPath, ['-e', code, filename], {
encoding: 'utf8',
env: { ...process.env, NODE_PATH: '' },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
});
@@ -0,0 +1,99 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
manualUpgradeFixture,
} = require('../../../test/helpers/manual-upgrade-fixture.cjs');
const { createContext } = require('../../dist/internal/runtime/context');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
const operator = require('../../dist/internal/maintenance/operator');
const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
for (const status of ['ready', 'failed', 'completed', 'absent'])
for (const wrongVersion of [false, true])
test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
await fs.writeFile(
path.join(f.root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
const file = path.join(f.tmp, 'panel-upgrade.json');
if (status === 'absent') await fs.unlink(file);
else
await fs.writeFile(
file,
JSON.stringify({
...(await f.state()),
status,
error: 'old error',
version: '9.9.9',
sourceCommit: 'f'.repeat(40),
}),
);
t.mock.method(operator, 'stopPanel', async () => {});
t.mock.method(operator, 'startPanel', async () => ({
manager: 'fixture',
}));
t.mock.method(artifacts, 'waitForHealth', async () => {});
const operation = reloadPanel(
createContext({ root: f.root }, f.env),
'system',
);
if (wrongVersion)
await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.sourceCommit, f.expected.sourceCommit);
assert.equal(state.version, f.expected.version);
assert.notEqual(state.error, 'old error');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
});
for (const wrongVersion of [false, true]) {
test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
const context = createContext({ root: f.root }, f.env);
const timeouts = [];
t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => {
timeouts.push(timeout);
});
t.mock.method(operator, 'stopPanel', async () => {
assert.equal((await f.state()).status, 'restarting');
});
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
const operation = reloadPanel(context, 'system');
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
assert.ok(timeouts.length > 0);
assert.ok(
timeouts.every((timeout) => timeout === undefined),
'use the shared ARMv6/default startup deadline',
);
assert.equal(
(await f.state()).status,
wrongVersion ? 'failed' : 'completed',
);
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
if (wrongVersion)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
});
}
+46
View File
@@ -0,0 +1,46 @@
# ARMv7 is a source of application files only. None of its system libraries or
# Node binaries are executed or copied into the ARMv6 runtime.
ARG SOURCE_IMAGE
FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source
# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers.
FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime
COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
debootstrap gpg gpgv ca-certificates curl qemu-user-static && \
rm -rf /var/lib/apt/lists/*
# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest
# access checks. Pin the upstream static tracer instead of misresolving paths.
RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \
echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \
chmod 755 /usr/local/bin/proot && proot --version
RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \
echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \
gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \
debootstrap --foreign --arch=armhf --variant=minbase \
--keyring=/usr/share/keyrings/raspbian.gpg \
bullseye /armv6 https://archive.raspbian.org/raspbian
COPY docker/armv6-run /usr/local/bin/armv6-run
RUN chmod 755 /usr/local/bin/armv6-run && \
DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \
cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \
mkdir -p /armv6/etc/ssl/certs && \
cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \
printf '127.0.0.1 localhost\n::1 localhost\n' > /armv6/etc/hosts && \
printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \
chmod 755 /armv6/usr/sbin/policy-rc.d && \
armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*'
# This Node is a pinned CI/test prerequisite, never part of the upgrade archive.
# Build against the minimum supported Node, including its bundled C++ headers.
RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \
echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \
tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz
COPY --from=source /ql/ /armv6/ql/
# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries.
COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/
RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \
armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -e '\''require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})'\''; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E "Tag_CPU_arch: v6($|K)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\'''
LABEL qinglong.qa.runtime="armv6-glibc-arm1176"
ENTRYPOINT ["/usr/local/bin/armv6-run"]
+30
View File
@@ -0,0 +1,30 @@
# The image digest is supplied by the same workflow run, never a rolling tag.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS bundle
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
USER root
# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon.
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \
cd "$(readlink -f /ql/node_modules/sqlite3)" && \
CFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
CXXFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \
npm_config_nodedir=/usr npm_config_build_from_source=true QEMU_CPU=arm1176 \
node -e 'require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})' && \
readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E 'Tag_CPU_arch: v6($|K)' && \
rm -rf build && \
apk del .ql-bundle-build; \
fi
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
export QEMU_CPU=arm1176; \
node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \
for archive in /out/*.tar.gz; do \
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-upgrade-bundle.cjs /ql /out; fi
FROM scratch
COPY --from=bundle /out/ /
@@ -0,0 +1,13 @@
# An immutable digest of the CI-only ARM1176 validator, built in the same run.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS bundle
ARG BASE_IMAGE
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
RUN ulimit -c 0; armv6-run /bin/bash -ec \
'node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out; \
for archive in /out/*.tar.gz; do \
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install; \
rm -rf /tmp/ql-bundle-offline-smoke; \
done'
FROM scratch
COPY --from=bundle /armv6/out/ /
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
export PROOT_NO_SECCOMP=1 QEMU_CPU=arm1176
# Bind only kernel interfaces; all application files and writable state remain
# inside this container's ARMv6 root. Child processes also pass through QEMU.
exec proot -0 -q '/usr/bin/qemu-arm-static -cpu arm1176' \
-r /armv6 -b /proc -b /dev -w / "$@"
@@ -0,0 +1,77 @@
# 平台完整升级包
CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。
## 在线升级
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
正式升级包跟随 master 同一次版本镜像构建,直接使用该次 Alpine/Debian 镜像的不可变 digest;develop 的普通提交和 Git tag 工作流不生成或发布完整包。开发包需要手动运行现有镜像工作流:选择 develop,并勾选 `build_development_bundles`(默认为 false,不能与 `static_sync_only` 同时启用)。也可执行:
```sh
gh workflow run build-docker-image.yml --ref develop -f build_development_bundles=true
```
手动运行会构建该次 develop 提交的两种镜像,并从输出的固定 digest 导出完整包,更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的手动发布串行执行,普通 develop CI 不受该队列影响。
开发和正式包都必须在 13 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。ARMv6 glibc 继续使用同次 Debian 应用源码及专用 ARM1176 构建和验证环境。
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
## 手动离线安装和升级
从 `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。
已有安装(默认应用并重启):
```sh
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
```
只准备,稍后手动重启:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
ql reload system
```
仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps
```
修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。
修复继承调用环境中的标准 HTTP/HTTPS/ALL/NO_PROXY(含小写形式),其余 Node 预加载和编译参数仍隔离。修复安装或 SQLite 重建失败时,清理私有目录,保留当前服务、当前依赖及上一次就绪包;不会标记本次更新就绪。预启动或修复主进程退出时立即终止其进程组,防止持有输出管道的子进程继续写入已清理目录。
通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。
手动应用会以当前就绪指针为准建立新的应用任务,并清除之前的失败信息。因此,准备失败后应用保留的旧就绪包、此前任务已完成后再次应用,以及完整包安装中任务记录丢失或尚未建立时,仍会校验实际启动版本并同步面板状态。未支持完整包且没有任务记录的旧版保留原重载兼容路径。
重启中断后的停止新服务、恢复文件和启动旧服务不再使用已经取消的信号。若新服务无法停止,则保留备份,避免在仍运行的进程下覆盖文件;若文件已恢复但旧服务也无法启动,任务仍报告失败。Bash 和本地 CLI 启动后端时都移除离线包选择、修复许可和升级启动器 PID,避免后续在线更新误用旧离线包;用户配置及代理保持不变。回滚恢复应用文件和依赖,不把用户数据库恢复到历史快照;数据库迁移仍需保持向后兼容。
安装到空目录:
```sh
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
```
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。
## 平台兼容
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
- ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
+4 -2
View File
@@ -127,7 +127,8 @@
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
"winston-daily-rotate-file": "^5.0.0",
"tar": "7.5.20"
},
"devDependencies": {
"@ant-design/icons": "^5.0.1",
@@ -201,5 +202,6 @@
"typescript": "5.2.2",
"vh-check": "^2.0.5",
"virtualizedtableforantd4": "1.3.0"
}
},
"qinglongUpgradeFormat": 2
}
+3 -5
View File
@@ -127,6 +127,9 @@ dependencies:
sqlite3:
specifier: npm:@whyour/sqlite3@1.1.2
version: /@whyour/sqlite3@1.1.2
tar:
specifier: 7.5.20
version: 7.5.20
toad-scheduler:
specifier: ^3.0.1
version: 3.0.1
@@ -3053,7 +3056,6 @@ packages:
dependencies:
minipass: 7.1.3
dev: false
optional: true
/@istanbuljs/load-nyc-config@1.1.0:
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
@@ -6623,7 +6625,6 @@ packages:
engines: {node: '>=18'}
requiresBuild: true
dev: false
optional: true
/ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
@@ -10701,7 +10702,6 @@ packages:
dependencies:
minipass: 7.1.3
dev: false
optional: true
/mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
@@ -14706,7 +14706,6 @@ packages:
minizlib: 3.1.0
yallist: 5.0.0
dev: false
optional: true
/terser@5.36.0:
resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==}
@@ -15727,7 +15726,6 @@ packages:
engines: {node: '>=18'}
requiresBuild: true
dev: false
optional: true
/yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
+22
View File
@@ -0,0 +1,22 @@
const path = require('node:path');
require('../cli/node_modules/esbuild').buildSync({
entryPoints: [path.resolve(__dirname, '../shell/install-bundle.cjs')],
outfile: path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
minify: true,
legalComments: 'inline',
});
const { spawnSync } = require('node:child_process');
const result = spawnSync(
process.execPath,
[path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs')],
{ encoding: 'utf8' },
);
if (
result.status !== 1 ||
!result.stderr.includes('Usage: node install-bundle.cjs')
)
throw new Error('Standalone offline installer did not start correctly');
+142
View File
@@ -0,0 +1,142 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const tar = require('tar');
const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs');
const {
verifyUpgrade,
} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
async function normalizeModes(directory) {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const filename = path.join(directory, entry.name);
if (entry.isDirectory()) {
await fs.chmod(filename, 0o755);
await normalizeModes(filename);
} else if (entry.isFile()) {
const stat = await fs.stat(filename);
await fs.chmod(filename, 0o644 | (stat.mode & 0o111));
}
}
}
async function buildBundle(root, output) {
root = path.resolve(root);
output = path.resolve(output);
const target = await bundle.runtimeTarget();
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'));
await bundle.smokeDependencies(root);
await fs.mkdir(output, { recursive: true });
const workspace = await fs.mkdtemp(path.join(output, '.bundle-'));
try {
const payload = path.join(workspace, 'qinglong');
const source = path.join(payload, 'source'),
staticRoot = path.join(payload, 'static');
await fs.mkdir(source, { recursive: true });
const excluded = new Set([
'.git',
'.tmp',
'.env',
'data',
'static',
'output',
'node_modules',
]);
for (const name of await fs.readdir(root)) {
if (excluded.has(name)) continue;
await fs.cp(path.join(root, name), path.join(source, name), {
recursive: true,
verbatimSymlinks: true,
});
}
await fs.cp(
path.join(root, 'node_modules'),
path.join(source, 'node_modules'),
{ recursive: true, verbatimSymlinks: true },
);
await fs.cp(path.join(root, 'static'), staticRoot, {
recursive: true,
verbatimSymlinks: true,
});
await normalizeModes(source);
await normalizeModes(staticRoot);
const manifest = {
format: 2,
sourceCommit: build.sourceCommit,
version: JSON.parse(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
).version,
lockfileSha256: build.lockfileSha256,
target: {
platform: target.platform,
arch: target.arch,
libc: target.libc,
libcMinimum: target.libcVersion,
endianness: target.endianness,
armFloatAbi: target.armFloatAbi,
},
nodeMinimum: '20.18.1',
nodeApi: 6,
files: await bundle.inventory({ source, static: staticRoot }),
};
await fs.writeFile(
path.join(payload, 'bundle.json'),
JSON.stringify(manifest),
);
console.log('Bundle inventory verified; writing archive');
const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey(
target,
)}.tar.gz`;
await tar.c(
{
gzip: true,
portable: true,
noMtime: true,
cwd: workspace,
file: path.join(output, filename),
},
['qinglong'],
);
// Verify the bytes users receive, not just the pre-archive build directory.
const roundtrip = path.join(workspace, 'roundtrip');
await fs.mkdir(roundtrip);
console.log('Verifying archived paths, permissions and checksums');
await bundle.extractArchive(path.join(output, filename), roundtrip);
const extracted = {
source: path.join(roundtrip, 'qinglong/source'),
static: path.join(roundtrip, 'qinglong/static'),
};
await bundle.verifyPayload(extracted, manifest, target);
await verifyUpgrade(extracted.source, extracted.static);
await bundle.smokeDependencies(extracted.source);
console.log('Archived production dependencies and SQLite verified');
const bytes = await fs.readFile(path.join(output, filename));
const record = {
format: 2,
sourceCommit: build.sourceCommit,
version: manifest.version,
target: bundle.targetKey(target),
filename,
baseImage: process.env.QL_BUNDLE_BASE_IMAGE,
size: bytes.length,
sha256: bundle.sha256(bytes),
};
await fs.writeFile(
path.join(output, filename + '.json'),
JSON.stringify(record) + '\n',
);
console.log(JSON.stringify(record));
return record;
} finally {
await fs.rm(workspace, { recursive: true, force: true });
}
}
module.exports = { buildBundle, normalizeModes };
if (require.main === module)
buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch(
(e) => {
console.error(e);
process.exitCode = 1;
},
);
+253
View File
@@ -0,0 +1,253 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const {
sha256,
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
const platforms = [
'linux-x64-musl',
'linux-armv6-musl',
'linux-armv7-musl',
'linux-arm64-musl',
'linux-ppc64-musl',
'linux-s390x-musl',
'linux-ia32-musl',
'linux-x64-glibc',
'linux-armv6-glibc',
'linux-armv7-glibc',
'linux-arm64-glibc',
'linux-ppc64-glibc',
'linux-s390x-glibc',
];
function releaseIndex(directory, commit, repository, 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 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,
commit,
repository,
branch,
version,
buildId,
allowDevelopment = false,
gh = (...args) =>
execFileSync('gh', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}),
}) {
if (
branch !== 'master' &&
!(branch === 'develop' && allowDevelopment === true)
)
throw new Error(
'Only master or explicitly requested development images publish bundles',
);
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || ''))
throw new Error('Invalid bundle version');
if (!/^\d+-\d+$/.test(buildId || ''))
throw new Error('Invalid bundle build ID');
// Rebuilding a version gets new download URLs, including when its commit is
// unchanged. Only the version/channel pointer is overwritten, never bytes
// that an in-flight download has already selected by hash.
const development = branch === 'develop';
const tag = `upgrade-build-${
development ? 'develop-' : ''
}v${version}-${buildId}`;
const index = {
...releaseIndex(directory, commit, repository, {
version,
releaseTag: tag,
}),
version,
buildId,
channel: branch,
};
const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs'));
index.installer = {
size: installer.length,
sha256: sha256(installer),
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
tag,
)}/install-bundle.cjs`,
};
fs.writeFileSync(
path.join(directory, 'latest.json'),
JSON.stringify(index) + '\n',
);
const lookup = (ref) => {
try {
return JSON.parse(
gh(
'api',
`repos/${repository}/releases/tags/${encodeURIComponent(ref)}`,
),
);
} catch (error) {
if (/\bHTTP 404\b/.test(String(error.stderr))) return null;
throw error;
}
};
const notes =
`Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
Object.entries(index.packages)
.map(([target, entry]) => `- [${target}](${entry.url})`)
.join('\n') +
`\n- [Standalone offline installer](${index.installer.url})`;
const ensureRelease = (ref, title) => {
if (!lookup(ref))
gh(
'release',
'create',
ref,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--latest=false',
'--title',
title,
'--notes',
notes,
);
};
const currentSource = () =>
gh(
'api',
`repos/${repository}/git/ref/heads/${branch}`,
'--jq',
'.object.sha',
).trim() === commit;
if (!currentSource()) {
console.log('Newer source exists; leaving version and channel unchanged');
return false;
}
ensureRelease(tag, `Platform upgrade v${version} (${buildId})`);
const assets = platforms
.flatMap((target) => {
const archive = path.join(
directory,
`qinglong-${commit}-${target}.tar.gz`,
);
return [archive, archive + '.json'];
})
.concat(
path.join(directory, 'install-bundle.cjs'),
path.join(directory, 'latest.json'),
);
const published = lookup(tag);
const missing = [];
const matches = (asset, file) => {
const bytes = fs.readFileSync(file);
return (
asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}`
);
};
for (const file of assets) {
const previous = published.assets.find(
(a) => a.name === path.basename(file),
);
if (!previous) missing.push(file);
else if (!matches(previous, file))
throw new Error(
`Immutable release asset differs: ${path.basename(file)}`,
);
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
const uploaded = lookup(tag);
if (
!assets.every((file) =>
matches(
uploaded.assets.find((a) => a.name === path.basename(file)),
file,
),
)
)
throw new Error('Published platform bundle verification failed');
// Each channel serializes its image builds and pointer writes as one release.
// A rerun of an earlier workflow is allowed to replace the same
// version/commit; an obsolete source commit must never advance either alias.
const aliases = development
? ['upgrade-develop']
: [`upgrade-v${version}`, 'upgrade-master'];
for (const ref of aliases) {
if (!currentSource()) {
console.log('Newer source exists; leaving version and channel unchanged');
return false;
}
ensureRelease(
ref,
ref === `upgrade-${branch}`
? `Upgrade channel ${branch}`
: `Platform upgrade v${version}`,
);
gh(
'release',
'upload',
ref,
path.join(directory, 'latest.json'),
'--repo',
repository,
'--clobber',
);
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
}
return true;
}
module.exports = { releaseIndex, platforms, publishBundles };
if (require.main === module) {
const [directory, commit, repository, branch] = process.argv.slice(2);
const version = require('../package.json').version;
if (
require('js-yaml').load(
fs.readFileSync(path.join(__dirname, '../version.yaml'), 'utf8'),
).version !== version
)
throw new Error('Package and image release versions differ');
publishBundles({
directory,
commit,
repository,
branch,
version,
buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
allowDevelopment:
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' &&
process.env.QL_BUILD_DEVELOPMENT_BUNDLES === 'true',
});
}
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env node
// Also published as a standalone, bundled installer for devices with Node only.
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { randomUUID } = require('node:crypto');
const { promisify } = require('node:util');
const execute = promisify(require('node:child_process').execFile);
const { parseArgs } = require('node:util');
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs');
const {
nodeRuntimeArgs,
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
async function runInstallerMain(argv = process.argv.slice(2)) {
const flags = nodeRuntimeArgs();
if (!flags.length || process.execArgv.includes('--jitless'))
return installBundle(argv);
const child = require('node:child_process').spawn(
process.execPath,
[...flags, __filename, ...argv],
{ stdio: 'inherit', env: process.env },
);
const interrupt = () => child.kill('SIGINT');
const terminate = () => child.kill('SIGTERM');
process.once('SIGINT', interrupt);
process.once('SIGTERM', terminate);
try {
await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`Offline installer exited: ${signal || code}`));
});
});
} finally {
process.removeListener('SIGINT', interrupt);
process.removeListener('SIGTERM', terminate);
}
}
async function installBundle(argv = process.argv.slice(2), env = process.env) {
const { values } = parseArgs({
args: argv,
options: {
root: { type: 'string' },
file: { type: 'string' },
'download-only': { type: 'boolean' },
install: { type: 'boolean' },
'repair-deps': { type: 'boolean' },
},
});
if (!values.file || !(values.root || env.QL_DIR))
throw new Error(
'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install] [--repair-deps]',
);
if (values.install && values['download-only'])
throw new Error('Choose --install or --download-only');
const root = path.resolve(values.root || env.QL_DIR);
const existing = await fs.readdir(root).catch((error) => {
if (error.code !== 'ENOENT') throw error;
return [];
});
if (values.install && existing.length)
throw new Error('--install requires an empty installation directory');
if (!values.install) await fs.access(path.join(root, 'shell/share.sh'));
let environment = {
...env,
QL_DIR: root,
QL_UPGRADE_FILE: path.resolve(values.file),
};
if (!values.install) {
const captured = await execute(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1/shell/share.sh"\n. "$1/shell/env.sh"\nimport_config\nexport QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}"\nenv -0',
'ql-offline',
root,
],
{ env: environment, maxBuffer: 1024 * 1024 },
);
const loaded = Object.fromEntries(
captured.stdout.split('\0').flatMap((line) => {
const match = /^([A-Za-z_][A-Za-z_0-9]*)=([\s\S]*)$/.exec(line);
return match ? [[match[1], match[2]]] : [];
}),
);
environment = {
...environment,
...loaded,
QL_DIR: root,
QL_UPGRADE_FILE: path.resolve(values.file),
};
}
environment.QL_UPGRADE_REPAIR_DEPS = values['repair-deps'] ? '1' : '0';
const freshWorkspace = values.install
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
: undefined;
const tmp = freshWorkspace || path.join(root, '.tmp');
const controller = new AbortController();
const abort = () => controller.abort(new Error('离线升级已中断'));
process.once('SIGINT', abort);
process.once('SIGTERM', abort);
try {
const operation = () =>
artifacts.withUpgradeLock(
tmp,
async () => {
let state;
const report = async (patch) => {
if (patch.phase) console.log(patch.phase);
if (state) {
state = {
...state,
...patch,
pid: process.pid,
updatedAt: Date.now(),
};
await jobs.saveState(tmp, state);
}
};
if (!values.install) {
const previous = await jobs.getStatus(tmp);
if (['starting', 'running', 'restarting'].includes(previous.status))
throw new Error('已有更新或重载正在执行。');
state = {
id: randomUUID(),
action: values['download-only'] ? 'download' : 'apply',
status: 'running',
phase: 'verifying',
startedAt: Date.now(),
};
await report({});
await fs.writeFile(
path.join(tmp, 'panel-upgrade.log'),
'Offline bundle verification\n',
{ mode: 0o600 },
);
}
try {
const selected = await artifacts.stageUpgrade({
root,
tmp,
env: environment,
run: (program, args, capture) =>
artifacts.runProcess(
environment,
program,
args,
capture,
controller.signal,
),
signal: controller.signal,
onProgress: report,
install: async () => {
throw new Error(
'Offline bundles never install npm dependencies',
);
},
});
if (state)
await report({
status: 'ready',
phase: 'ready',
...(await jobs.stagedMetadata(selected)),
});
if (values['download-only']) {
console.log('更新已就绪;执行 ql reload system 应用更新。');
return selected;
}
if (values.install) {
// Build beside the destination, then atomically replace only an empty root.
await fs.mkdir(path.dirname(root), { recursive: true });
const publication = await fs.mkdtemp(
path.join(path.dirname(root), '.qinglong-install-'),
);
try {
await fs.chmod(publication, 0o755);
await fs.cp(selected.source, publication, {
recursive: true,
verbatimSymlinks: true,
});
await fs.cp(selected.static, path.join(publication, 'static'), {
recursive: true,
});
await fs.copyFile(
path.join(publication, '.env.example'),
path.join(publication, '.env'),
require('node:fs').constants.COPYFILE_EXCL,
);
await fs.chmod(path.join(publication, '.env'), 0o600);
controller.signal.throwIfAborted();
await fs.rename(publication, root);
} finally {
await fs.rm(publication, { recursive: true, force: true });
}
await fs.rm(path.dirname(selected.source), {
recursive: true,
force: true,
});
await fs.rm(
path.join(
tmp,
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
),
{ force: true },
);
console.log(
`离线安装完成,保留现有 Node ${
process.version
}。启动服务:QL_DIR=${JSON.stringify(
root,
)} QL_DATA_DIR=${JSON.stringify(
path.join(root, 'data'),
)} bash ${JSON.stringify(
path.join(root, 'shell/start-simplify.sh'),
)}`,
);
return;
}
const result = await jobs.withPanelLifecycle(
root,
tmp,
environment,
async (hook, prepareSelection) => {
const expected = await jobs.stagedMetadata(selected);
const previous = await jobs
.readRunningBuild(environment)
.catch(() => undefined);
const selection = await prepareSelection?.(selected);
try {
return await artifacts.reloadSystem({
root,
tmp,
env: environment,
data: environment.dir_data || path.join(root, 'data'),
staticRoot: path.join(root, 'static'),
config:
environment.dir_config || path.join(root, 'data/config'),
lifecycle: {
signal: controller.signal,
stop: async (recover) => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook(
'stop',
[],
recover ? undefined : controller.signal,
);
},
start: async (recover) => {
await hook(
'start',
[],
recover ? undefined : controller.signal,
);
await artifacts.waitForHealth(
environment,
undefined,
recover ? undefined : controller.signal,
);
if (!recover)
await jobs.verifyRunningVersion(
environment,
expected,
previous,
);
},
},
additionalReplacements: selection
? [{ source: selection.source, target: selection.target }]
: [],
});
} finally {
if (selection)
await fs.rm(selection.directory, {
recursive: true,
force: true,
});
}
},
controller.signal,
);
await report({ status: 'completed', phase: 'completed' });
console.log('升级成功,已保留现有 Node 并运行更新后的版本。');
return result;
} catch (error) {
if (state) await report({ status: 'failed', error: error.message });
throw error;
}
},
environment,
);
return values.install
? await operation()
: await artifacts.withUpgradeLock(
path.join(tmp, 'panel-job-launch'),
operation,
environment,
);
} finally {
process.removeListener('SIGINT', abort);
process.removeListener('SIGTERM', abort);
if (freshWorkspace)
await fs.rm(freshWorkspace, { recursive: true, force: true });
}
}
module.exports = { installBundle, runInstallerMain };
if (require.main === module)
runInstallerMain().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+11
View File
@@ -52,6 +52,17 @@ make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
# Slow devices may need more than the worker's readiness window to generate
# first-boot certificates. Reuse the regular initializer before starting workers.
node <<'QL_PREPARE_GRPC_CERTS'
require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js'))
.initGrpcCerts()
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
QL_PREPARE_GRPC_CERTS
pm2 l &>/dev/null
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
+7 -5
View File
@@ -2,7 +2,10 @@ import { CloudDownloadOutlined } from '@ant-design/icons';
import { Modal, Tooltip } from 'antd';
import { useState } from 'react';
import intl from 'react-intl-universal';
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade';
import useUpgrade, {
activeStatuses,
upgradeStatusLabel,
} from '@/hooks/useUpgrade';
import CheckUpdate from '@/pages/setting/checkUpdate';
export default function UpgradeNotice({
@@ -21,16 +24,15 @@ export default function UpgradeNotice({
if (!showNotice && !open) return null;
const text = !connected
? intl.get('连接已断开,正在重新连接')
: job.status === 'failed'
? intl.get('更新失败')
: intl.get(phaseLabels[job.phase || ''] || '检查更新');
: intl.get(upgradeStatusLabel(job));
const label =
activeStatuses.includes(job.status) &&
job.phase === 'downloading' && job.percent != null
? `${text} ${job.percent}%`
: text;
return (
<>
{showNotice && (
{showNotice && !open && (
<Tooltip title={label} placement="right">
<button
type="button"
+15
View File
@@ -25,11 +25,26 @@ export const phaseLabels: Record<string, string> = {
extracting: '正在解压更新包',
verifying: '正在校验更新包',
dependencies: '正在准备依赖',
prestarting: '正在预启动验证',
repairing: '正在修复运行依赖',
ready: '更新已就绪',
restarting: '正在重启并更新',
completed: '更新完成',
};
// Status is authoritative; old progress fields must not claim completion.
export function upgradeStatusLabel(job: UpgradeJob): string {
if (job.status === 'ready') return '下载完成,等待重启';
if (job.status === 'completed') return '升级成功';
if (job.status === 'failed') return '更新失败';
if (activeStatuses.includes(job.status)) {
return !['ready', 'completed'].includes(job.phase || '')
? phaseLabels[job.phase || ''] || '正在准备更新'
: '正在准备更新';
}
return '检查更新';
}
// The sidebar and settings share one poller and one mutation state.
export function createUpgradeStore() {
let snapshot = {
+5
View File
@@ -346,6 +346,11 @@ select:-webkit-autofill:focus {
.ant-pro-global-header {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03);
.ant-pro-global-header-collapsed-button {
min-width: 0;
max-width: 100%;
}
}
.ant-menu-item.ant-pro-sider-collapsed-button {
+3
View File
@@ -734,6 +734,8 @@
"从开始至load总耗时": "Total load time: {duration} ms",
"正在解压更新包": "Extracting update",
"正在校验更新包": "Verifying update",
"正在预启动验证": "Verifying backend startup",
"正在修复运行依赖": "Repairing runtime dependencies",
"正在准备依赖": "Preparing dependencies",
"更新已就绪": "Update ready",
"正在重启并更新": "Restarting and updating",
@@ -744,6 +746,7 @@
"更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.",
"更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.",
"下载构建产物": "Downloading application files",
"下载更新包": "Downloading update package",
"下载源码": "Downloading source files",
"正在获取下载大小": "Total size unavailable",
"查看更新日志": "View update log",
+3
View File
@@ -734,6 +734,8 @@
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
"正在解压更新包": "正在解压更新包",
"正在校验更新包": "正在校验更新包",
"正在预启动验证": "正在预启动验证",
"正在修复运行依赖": "正在修复运行依赖",
"正在准备依赖": "正在准备依赖",
"更新已就绪": "更新已就绪",
"正在重启并更新": "正在重启并更新",
@@ -744,6 +746,7 @@
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
"下载构建产物": "下载构建产物",
"下载更新包": "下载更新包",
"下载源码": "下载源码",
"正在获取下载大小": "正在获取下载大小",
"查看更新日志": "查看更新日志",
+5 -10
View File
@@ -5,7 +5,7 @@ import { Button, Modal, Progress, Space, Typography, message } from 'antd';
import { useState } from 'react';
import useUpgrade, {
activeStatuses,
phaseLabels,
upgradeStatusLabel,
reloadKey,
upgradeStore,
} from '@/hooks/useUpgrade';
@@ -100,14 +100,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
onOk: () => reloadSystem(true),
});
const label =
job.status === 'failed'
? intl.get('更新失败')
: ready
? intl.get('下载完成,等待重启')
: completed
? intl.get('升级成功')
: intl.get(phaseLabels[job.phase || ''] || '正在准备更新');
const label = intl.get(upgradeStatusLabel(job));
const hasJob = job.status !== 'idle' && job.status !== 'loading';
const actions = !active && (
<Space wrap>
@@ -167,7 +160,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
<div className="upgrade-progress-meta">
<Typography.Text type="secondary">
{intl.get(
job.artifact === 'qinglong-static'
job.artifact === 'qinglong-bundle'
? '下载更新包'
: job.artifact === 'qinglong-static'
? '下载构建产物'
: '下载源码',
)}
+24 -22
View File
@@ -1,27 +1,29 @@
import { useState, useEffect, useMemo } from 'react';
import browserType from './index';
import { useState, useEffect } from 'react';
export const useCtx = () => {
const [width, setWidth] = useState('100%');
const [marginLeft, setMarginLeft] = useState(0);
const [marginTop, setMarginTop] = useState(-48);
const [isPhone, setIsPhone] = useState(false);
const { platform } = useMemo(() => browserType(), []);
// Match ProLayout's xs/sm breakpoint, including narrow desktop windows.
const [isPhone, setIsPhone] = useState(
() =>
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 767.99px)').matches,
);
useEffect(() => {
if (platform === 'mobile' && document.body.offsetWidth < 768) {
setWidth('auto');
setMarginLeft(0);
setMarginTop(0);
setIsPhone(true);
document.body.setAttribute('data-mode', 'phone');
} else {
setWidth('100%');
setMarginLeft(0);
setMarginTop(-48);
setIsPhone(false);
document.body.setAttribute('data-mode', 'desktop');
const media = window.matchMedia('(max-width: 767.99px)');
const update = () => {
setIsPhone(media.matches);
document.body.setAttribute(
'data-mode',
media.matches ? 'phone' : 'desktop',
);
};
update();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}
media.addListener(update);
return () => media.removeListener(update);
}, []);
return {
@@ -31,9 +33,9 @@ export const useCtx = () => {
top: 0,
left: 0,
zIndex: 20,
marginTop,
width,
marginLeft,
marginTop: isPhone ? 0 : -48,
width: isPhone ? 'auto' : '100%',
marginLeft: 0,
} as any,
isPhone,
};
+101
View File
@@ -0,0 +1,101 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { EventEmitter } = require('node:events');
const ts = require('typescript');
const source = ts.createSourceFile(
'app.ts',
fs.readFileSync('back/app.ts', 'utf8'),
ts.ScriptTarget.Latest,
true,
);
const application = source.statements.find(
(node) => ts.isClassDeclaration(node) && node.name.text === 'Application',
);
const method = application.members.find(
(node) => node.name?.getText(source) === 'startMasterProcess',
);
const compiled = ts.transpileModule(
`class Application { ${method.getText(
source,
)} }\nmodule.exports = Application;`,
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
},
).outputText;
for (const [arch, armVersion, timeout] of [
['arm', 6, 120000],
['arm', 7, 30000],
['arm64', undefined, 30000],
]) {
test(`${arch} v${
armVersion ?? ''
} waits for scheduler readiness before HTTP and job recovery`, async () => {
const cluster = new EventEmitter();
const module = { exports: {} };
const logger = { info() {}, warn() {}, error() {} };
new Function(
'module',
'cluster',
'Logger',
'errStack',
'process',
compiled,
)(module, cluster, logger, String, {
arch,
config: { variables: { arm_version: armVersion } },
exit() {
assert.fail('unexpected application exit');
},
});
const app = new module.exports();
app.workerMetadataMap = new Map();
const forks = [],
waits = [],
notifications = [];
let ready;
app.forkWorker = (serviceType) => {
const worker = {
id: forks.length + 1,
process: { pid: 100 + forks.length },
send: (message) => notifications.push(message),
};
forks.push({ serviceType, worker });
app.workerMetadataMap.set(worker.id, { serviceType });
return worker;
};
app.waitForWorkerReady = (worker, milliseconds) => {
waits.push({ worker, milliseconds });
return new Promise((resolve) => {
ready = resolve;
});
};
app.setupMasterShutdown = () => {};
app.startMasterProcess();
assert.deepEqual(
forks.map((fork) => fork.serviceType),
['grpc'],
);
assert.equal(waits[0].milliseconds, timeout);
ready();
await Promise.resolve();
assert.deepEqual(
forks.map((fork) => fork.serviceType),
['grpc', 'http'],
);
cluster.emit('exit', forks[0].worker, 1);
assert.equal(waits[1].milliseconds, timeout);
assert.deepEqual(notifications, ['scheduler-unavailable']);
ready();
await Promise.resolve();
assert.deepEqual(notifications, [
'scheduler-unavailable',
'reregister-crons',
]);
});
}
+157
View File
@@ -0,0 +1,157 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execute = promisify(execFile);
const {
manualUpgradeFixture,
} = require('../helpers/manual-upgrade-fixture.cjs');
const jobs = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
for (const previousStatus of ['ready', 'failed', 'completed', 'idle', 'absent'])
for (const wrongVersion of [false, true])
test(`manual application records the selected package after ${previousStatus}; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
await fs.writeFile(
path.join(f.root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
const file = path.join(f.tmp, 'panel-upgrade.json');
if (previousStatus === 'absent') await fs.unlink(file);
else
await fs.writeFile(
file,
JSON.stringify({
...(await f.state()),
status: previousStatus,
error: 'previous preparation failed',
version: '9.9.9',
sourceCommit: 'f'.repeat(40),
backendSha256: 'f'.repeat(64),
stageDirectory: 'upgrade-other',
}),
);
const operation = execute(
process.execPath,
[path.resolve('shell/upgrade.cjs'), 'reload'],
{ env: f.env, timeout: 15000 },
);
if (wrongVersion)
await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.sourceCommit, f.expected.sourceCommit);
assert.equal(state.stageDirectory, f.expected.stageDirectory);
assert.equal(state.version, f.expected.version);
assert.notEqual(state.id, 'offline-prepared');
assert.equal(state.action, 'apply');
assert.notEqual(state.error, 'previous preparation failed');
if (!wrongVersion) assert.equal(state.error, undefined);
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
});
test('manual application rejects a selection that no longer matches the ready pointer', async (t) => {
const f = await manualUpgradeFixture(t);
let called = false;
await assert.rejects(
jobs.withPreparedJob(
f.tmp,
f.env,
{ ...f.selected, source: f.selected.source + '-stale' },
async () => {
called = true;
},
),
/暂存更新已变化/,
);
assert.equal(called, false);
assert.equal((await f.state()).status, 'ready');
});
test('legacy manual reload without a job or bundle capability stays self-contained', async (t) => {
const f = await manualUpgradeFixture(t);
await fs.unlink(path.join(f.tmp, 'panel-upgrade.json'));
await fs.unlink(path.join(f.tmp, 'upgrade-ready-develop.json'));
const result = await jobs.withPreparedJob(
f.tmp,
f.env,
f.selected,
async (job) => {
assert.equal(job, undefined);
return 'legacy';
},
);
assert.equal(result, 'legacy');
await assert.rejects(fs.access(path.join(f.tmp, 'panel-upgrade.json')), {
code: 'ENOENT',
});
});
for (const wrongVersion of [false, true]) {
test(`Bash reload consumes offline readiness and records the actual result; wrong version=${wrongVersion}`, async (t) => {
const f = await manualUpgradeFixture(t, wrongVersion);
const operation = execute(
process.execPath,
[path.resolve('shell/upgrade.cjs'), 'reload'],
{ env: f.env, timeout: 15000 },
);
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
else await operation;
const state = await f.state();
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
assert.equal(state.action, 'apply');
assert.notEqual(state.id, 'offline-prepared');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
wrongVersion ? 'old backend' : 'new backend',
);
assert.equal(
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
'preserve configuration',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
if (wrongVersion)
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
else
await assert.rejects(
fs.access(path.join(f.tmp, 'upgrade-ready-develop.json')),
);
});
}
test('manual application holds the launch lock and does not allow a competing panel job', async (t) => {
const f = await manualUpgradeFixture(t);
await assert.rejects(
jobs.withPreparedJob(f.tmp, f.env, f.selected, async (job) => {
assert.equal((await f.state()).status, 'running');
await assert.rejects(
jobs.startJob({ root: f.root, tmp: f.tmp, env: f.env }, 'apply'),
/已有更新/,
);
await job.onStop();
assert.equal((await f.state()).status, 'restarting');
throw new Error('cancel manual operation');
}),
/cancel manual operation/,
);
assert.equal((await f.state()).status, 'failed');
await assert.rejects(
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
);
});
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const {
manualUpgradeFixture,
} = require('../helpers/manual-upgrade-fixture.cjs');
const artifacts = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
const { installBundle } = require('../../shell/install-bundle.cjs');
for (const scenario of ['cancel-new-start', 'both-starts-fail'])
test(`offline application restores files and records failure: ${scenario}`, async (t) => {
const f = await manualUpgradeFixture(t);
t.mock.method(artifacts, 'stageUpgrade', async () => f.selected);
const journal = path.join(f.root, 'lifecycle.jsonl');
await fs.writeFile(
path.join(f.root, 'lifecycle.cjs'),
`
const fs=require('node:fs'),path=require('node:path');
const root=process.env.QL_DIR,op=process.argv[2];
const version=fs.readFileSync(path.join(root,'static/build/app.js'),'utf8');
fs.appendFileSync(${JSON.stringify(
journal,
)},JSON.stringify({op,version})+'\\n');
if(op==='start') {
if(${JSON.stringify(scenario)}==='both-starts-fail') process.exit(9);
if(version==='new backend') {
process.kill(Number(process.env.TEST_INSTALLER_PID),'SIGTERM');
setTimeout(()=>{},10000);
}
}
`,
);
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
`
import_config(){ :; }
delete_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" stop; }
reload_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" start; }
`,
);
await assert.rejects(
installBundle(
['--root', f.root, '--file', path.join(f.root, 'unused.tar.gz')],
{
...f.env,
TEST_NODE: process.execPath,
TEST_INSTALLER_PID: String(process.pid),
},
),
scenario === 'cancel-new-start' ? /离线升级已中断/ : /failed/,
);
const calls = (await fs.readFile(journal, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(calls, [
{ op: 'stop', version: 'old backend' },
{ op: 'start', version: 'new backend' },
{ op: 'stop', version: 'new backend' },
{ op: 'start', version: 'old backend' },
]);
assert.equal((await f.state()).status, 'failed');
assert.equal(
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
'old backend',
);
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
'old dependency',
);
assert.equal(
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
'preserve configuration',
);
assert.equal(
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
'preserve data',
);
await fs.access(path.join(f.tmp, 'upgrade-ready-develop.json'));
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')), {
code: 'ENOENT',
});
await assert.rejects(
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
{ code: 'ENOENT' },
);
assert.ok(
!(await fs.readdir(f.root)).some((n) => n.includes('.ql-backup-')),
);
});
+22 -3
View File
@@ -186,7 +186,7 @@ test('detached download survives its initiating request and is idempotent; apply
);
});
test('invalid staged payload is rejected before spawning a restart', async (t) => {
test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
@@ -197,9 +197,16 @@ test('invalid staged payload is rejected before spawning a restart', async (t) =
path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'),
'corrupted',
);
await assert.rejects(startJob(f, 'apply'), /checksum|损坏/);
const queued = await startJob(f, 'apply');
assert.equal(queued.status, 'starting');
const failed = await finished(f.tmp);
assert.equal(failed.status, 'failed');
assert.match(failed.error, /checksum|损坏/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
assert.equal((await getStatus(f.tmp)).status, 'failed');
assert.equal(
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
'version: 2.23.0',
);
});
test('failed download persists its failure without stopping the panel and can be retried', async (t) => {
@@ -467,3 +474,15 @@ for (const knownSize of [true, false])
assert.equal(reports.at(-1).downloadedBytes, 100);
assert.equal(reports.at(-1).percent, knownSize ? 100 : null);
});
test('asynchronous apply still rejects unsafe stage pointers before dispatch', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
assert.equal((await finished(f.tmp)).status, 'ready');
await fs.writeFile(
path.join(f.tmp, 'upgrade-ready-master.json'),
JSON.stringify({ directory: '../outside' }),
);
await assert.rejects(startJob(f, 'apply'), /指针|pointer/);
assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false);
});
+814
View File
@@ -0,0 +1,814 @@
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 tar = require('tar');
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
const artifacts = require('../../shell/upgrade.cjs');
for (const failure of ['prestart', 'repair'])
test(`failed ${failure} preserves the installed dependencies and previous ready pointer`, async (t) => {
const f = await fixture(t);
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
await fs.mkdir(path.join(f.root, 'node_modules'));
await fs.writeFile(
path.join(f.root, 'node_modules/keep'),
'old dependency',
);
let repairs = 0;
await assert.rejects(
bundle.stageBundle({
root: f.root,
tmp: f.tmp,
env: {
QL_BRANCH: 'develop',
QL_UPGRADE_FILE: f.archive,
QL_UPGRADE_REPAIR_DEPS: '1',
},
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {},
prestart: async () => {
if (failure === 'prestart') throw new Error('SQLITE_CANTOPEN');
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
code: 'MODULE_NOT_FOUND',
});
},
repair: async (source) => {
repairs++;
await fs.writeFile(
path.join(source, 'node_modules/partial-repair'),
'partial',
);
throw new Error('registry unavailable');
},
}),
failure === 'prestart' ? /SQLITE_CANTOPEN/ : /registry unavailable/,
);
assert.equal(repairs, failure === 'prestart' ? 0 : 1);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
assert.equal(
await fs.readFile(path.join(f.root, 'node_modules/keep'), 'utf8'),
'old dependency',
);
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
});
test('ready is published only after prestart; dependency failure repairs once and keeps application hashes', async (t) => {
const f = await fixture(t);
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
let probes = 0,
repairs = 0,
starts = 0;
const options = {
root: f.root,
tmp: f.tmp,
env: {
QL_BRANCH: 'develop',
QL_UPGRADE_FILE: f.archive,
QL_UPGRADE_REPAIR_DEPS: '1',
},
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {
if (++probes === 1)
throw Object.assign(
new Error(
'dlopen node_modules/sqlite3/node.node: invalid ELF header',
),
{ code: 'ERR_DLOPEN_FAILED' },
);
},
repair: async (source) => {
repairs++;
await fs.writeFile(
path.join(source, 'node_modules/sqlite3/repaired'),
'rebuilt for this device',
);
},
prestart: async () => {
starts++;
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
},
};
const selected = await bundle.stageBundle(options);
assert.equal(repairs, 1);
assert.equal(probes, 2);
assert.equal(starts, 1);
const receipt = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'prestart.json'),
),
);
assert.ok(receipt.completedAt);
assert.ok(
receipt.dependencyFiles[
'source/node_modules/.pnpm/sqlite/node_modules/sqlite3/repaired'
],
);
await fs.writeFile(
path.join(selected.source, 'version.yaml'),
'version: 9.9.9',
);
await assert.rejects(bundle.verifyStagedBundle(selected), /checksums/);
});
test('offline dependency failure never repairs by default; a failed retry keeps old readiness', async (t) => {
const f = await fixture(t),
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
let repairs = 0,
starts = 0;
const options = {
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {},
prestart: async () => {
starts++;
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
code: 'MODULE_NOT_FOUND',
});
},
repair: async () => {
repairs++;
},
run: () => assert.fail('offline must not access network'),
};
await assert.rejects(
bundle.stageBundle(options),
/no network access attempted/,
);
assert.equal(repairs, 0);
await assert.rejects(
bundle.stageBundle({
...options,
env: { ...options.env, QL_UPGRADE_REPAIR_DEPS: '1' },
}),
/Cannot find module/,
);
assert.equal(repairs, 1);
assert.equal(starts, 3);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
});
test('dependency repair cannot change verified source or static files', async (t) => {
const f = await fixture(t);
let starts = 0;
await assert.rejects(
bundle.stageBundle({
root: f.root,
tmp: f.tmp,
env: {
QL_BRANCH: 'develop',
QL_UPGRADE_FILE: f.archive,
QL_UPGRADE_REPAIR_DEPS: '1',
},
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
code: 'MODULE_NOT_FOUND',
});
},
repair: async (source) =>
fs.writeFile(path.join(source, 'version.yaml'), 'version: 9.9.9'),
prestart: async () => {
starts++;
},
}),
/checksums/,
);
assert.equal(starts, 0);
assert.deepEqual(await fs.readdir(f.tmp), []);
});
test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const preload = path.join(directory, 'armv6.cjs');
const observed = path.join(directory, 'child.json');
await fs.writeFile(
preload,
`if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify(
observed,
)},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`,
);
const { execFile } = require('node:child_process');
const result = await new Promise((resolve) =>
execFile(
process.execPath,
[path.resolve(__dirname, '../../shell/install-bundle.cjs')],
{
timeout: 10000,
env: {
...process.env,
NODE_OPTIONS: '--require ' + JSON.stringify(preload),
},
},
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
),
);
assert.equal(result.error.code, 1);
const child = JSON.parse(await fs.readFile(observed));
assert.ok(child.args.includes('--jitless'));
assert.equal(child.execPath, process.execPath);
assert.match(result.stderr, /Usage: node install-bundle.cjs/);
});
const {
writeManifest,
sourceCommit,
} = require('../helpers/upgrade-fixture.cjs');
const {
releaseIndex,
platforms,
} = require('../../scripts/publish-upgrade-bundles.cjs');
async function fixture(t) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bundle-test-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const root = path.join(directory, 'installed'),
tmp = path.join(root, '.tmp');
const payload = path.join(directory, 'qinglong');
const source = path.join(payload, 'source'),
staticRoot = path.join(payload, 'static');
await fs.mkdir(tmp, { recursive: true });
await fs.mkdir(
path.join(source, 'node_modules/.pnpm/sqlite/node_modules/sqlite3'),
{ recursive: true },
);
await fs.symlink(
'.pnpm/sqlite/node_modules/sqlite3',
path.join(source, 'node_modules/sqlite3'),
);
await fs.writeFile(
path.join(source, 'node_modules/sqlite3/package.json'),
'{"name":"sqlite3"}',
);
await fs.mkdir(path.join(source, 'sample'));
await fs.writeFile(path.join(source, 'sample/config.sample.sh'), '# config');
await fs.writeFile(
path.join(source, 'package.json'),
'{"version":"2.24.0","qinglongUpgradeFormat":2}',
);
await fs.writeFile(
path.join(root, 'package.json'),
'{"qinglongUpgradeFormat":2}',
);
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'backend');
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.24.0\n');
await fs.mkdir(path.join(staticRoot, 'build/bootstrap'));
await fs.writeFile(
path.join(staticRoot, 'build/bootstrap/upgradeProbe.js'),
`
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
if(process.env.NODE_OPTIONS || process.env.NODE_PATH || process.env.QL_CLI_ROOT)throw new Error('Leaked host environment');
const info=JSON.parse(fs.readFileSync(path.join(__dirname,'../../build-info.json')));
process.send({type:'upgrade-probe',ok:true,build:{version:'2.24.0',sourceCommit:info.sourceCommit,
backendSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(__dirname,'../app.js'))).digest('hex')}});
process.disconnect();
`,
);
writeManifest(source, staticRoot);
const target = await bundle.runtimeTarget();
const manifest = {
format: 2,
sourceCommit,
lockfileSha256: bundle.sha256(
await fs.readFile(path.join(source, 'pnpm-lock.yaml')),
),
target: { ...target, libcMinimum: target.libcVersion },
nodeMinimum: '20.18.1',
nodeApi: 6,
files: await bundle.inventory({ source, static: staticRoot }),
};
const archive = path.join(directory, 'package.tar.gz');
const pack = async () => {
await fs.writeFile(
path.join(payload, 'bundle.json'),
JSON.stringify(manifest),
);
await tar.c(
{
file: archive,
cwd: directory,
gzip: true,
portable: true,
noMtime: true,
},
['qinglong'],
);
};
await pack();
return {
directory,
root,
tmp,
source,
staticRoot,
target,
manifest,
archive,
pack,
};
}
test('online and offline stage the same complete payload, preserve pnpm links and never install dependencies', async (t) => {
const f = await fixture(t),
bytes = await fs.readFile(f.archive),
progress = [];
const entry = {
size: bytes.length,
sha256: bundle.sha256(bytes),
url: 'https://example.invalid/immutable.tar.gz',
};
let calls = 0,
probes = 0;
const run = async (program, args) => {
assert.equal(program, 'curl');
calls++;
const output = args[args.indexOf('--output') + 1];
if (args.at(-1).endsWith('latest.json'))
await fs.writeFile(
output,
JSON.stringify({
format: 2,
sourceCommit,
packages: { [bundle.targetKey(f.target)]: entry },
}),
);
else {
await fs.writeFile(output, bytes);
await fs.writeFile(
args[args.indexOf('--dump-header') + 1],
'HTTP/2 200 OK\r\n\r\n',
);
}
};
const options = {
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop' },
run,
verifyStatic: artifacts.verifyUpgrade,
download: artifacts.downloadWithProgress,
probe: async () => {
probes++;
},
onProgress: async (p) => progress.push(p),
};
const online = await bundle.stageBundle(options);
const first = await bundle.inventory(online);
assert.equal(calls, 2);
assert.equal(probes, 1);
assert.ok(progress.some((p) => p.percent === 100));
assert.ok(progress.some((p) => p.percent === 0));
const offline = await bundle.stageBundle({
...options,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
run: () => assert.fail('offline network access'),
});
assert.equal(probes, 2);
assert.deepEqual(await bundle.inventory(offline), first);
assert.equal(
await fs.readlink(path.join(offline.source, 'node_modules/sqlite3')),
'.pnpm/sqlite/node_modules/sqlite3',
);
await assert.rejects(fs.access(online.source));
});
test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains the existing Node requirement', async (t) => {
const f = await fixture(t),
target = {
platform: 'linux',
arch: 'armv6',
libc: 'musl',
libcVersion: '1.2.5',
};
const manifest = {
...f.manifest,
target: { ...target, libcMinimum: '1.2.4' },
};
bundle.assertCompatible(manifest, target, '20.20.2');
const glibcTarget = {
...target,
libc: 'glibc',
libcVersion: '2.31',
endianness: 'LE',
armFloatAbi: 'hard',
};
const glibcManifest = {
...manifest,
target: { ...glibcTarget, libcMinimum: '2.31' },
};
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc');
assert.throws(
() =>
bundle.assertCompatible(glibcManifest, {
...glibcTarget,
libcVersion: '2.30',
}),
/requires glibc/,
);
assert.throws(
() =>
bundle.assertCompatible(glibcManifest, {
...glibcTarget,
armFloatAbi: 'soft',
}),
/floating-point ABI mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
/platform mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, { ...target, libc: 'glibc' }),
/platform mismatch/,
);
assert.throws(
() => bundle.assertCompatible(manifest, target, '18.20.0'),
/requires Node/,
);
assert.throws(
() =>
bundle.assertCompatible(manifest, { ...target, libcVersion: '1.1.24' }),
/requires musl/,
);
});
test('dependency tampering and payload link escapes are rejected', async (t) => {
const f = await fixture(t),
selected = { source: f.source, static: f.staticRoot };
await bundle.verifyPayload(selected, f.manifest, f.target);
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/package.json'),
'{"tampered":true}',
);
await assert.rejects(
bundle.verifyPayload(selected, f.manifest, f.target),
/checksums/,
);
await fs.symlink('/etc/passwd', path.join(f.source, 'outside'));
await assert.rejects(bundle.inventory(selected), /escapes/);
});
test('archive traversal, absolute links and writes through internal symlinks are rejected before extraction', async (t) => {
const f = await fixture(t),
dest = path.join(f.directory, 'extract');
await fs.mkdir(dest);
await fs.symlink('/etc', path.join(f.directory, 'qinglong/bad'));
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
'qinglong/bad',
]);
await assert.rejects(bundle.extractArchive(f.archive, dest), /symlink/);
await tar.c(
{ cwd: f.directory, file: f.archive, gzip: true, prefix: '../outside' },
['qinglong/source/package.json'],
);
await assert.rejects(bundle.extractArchive(f.archive, dest), /Unsafe/);
await fs.mkdir(path.join(f.directory, 'qinglong/real'));
await fs.writeFile(path.join(f.directory, 'qinglong/real/file'), 'value');
await fs.symlink('real', path.join(f.directory, 'qinglong/link'));
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
'qinglong/link',
'qinglong/link/file',
]);
await assert.rejects(
bundle.extractArchive(f.archive, dest),
/through a symlink/,
);
assert.deepEqual(await fs.readdir(dest), []);
});
test('an incomplete or corrupt platform download cannot replace an existing ready pointer', async (t) => {
const f = await fixture(t),
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-ready');
await assert.rejects(
bundle.stageBundle({
root: f.root,
tmp: f.tmp,
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
verifyStatic: artifacts.verifyUpgrade,
probe: async () => {
throw new Error('native addon failed');
},
}),
/native addon failed/,
);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
assert.equal(
(await fs.readdir(f.tmp)).filter((n) => n.startsWith('upgrade-')).length,
1,
);
});
test('publishing requires every platform and validates all immutable archive hashes', async (t) => {
const f = await fixture(t),
repository = 'whyour/qinglong';
assert.throws(() => releaseIndex(f.directory, sourceCommit, repository));
for (const target of platforms) {
const filename = `qinglong-${sourceCommit}-${target}.tar.gz`,
bytes = Buffer.from(target);
await fs.writeFile(path.join(f.directory, filename), bytes);
await fs.writeFile(
path.join(f.directory, filename + '.json'),
JSON.stringify({
format: 2,
sourceCommit,
target,
filename,
size: bytes.length,
sha256: bundle.sha256(bytes),
}),
);
}
const index = releaseIndex(f.directory, sourceCommit, repository);
assert.equal(Object.keys(index.packages).length, 13);
const armv6 = path.join(
f.directory,
`qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`,
);
const armv6Bytes = await fs.readFile(armv6);
await fs.rm(armv6);
assert.throws(
() => releaseIndex(f.directory, sourceCommit, repository),
/ENOENT/,
);
await fs.writeFile(armv6, armv6Bytes);
await fs.appendFile(
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
'corrupt',
);
assert.throws(
() => releaseIndex(f.directory, sourceCommit, repository),
/Unverified/,
);
});
test('fresh offline installation needs no package manager, keeps Node and publishes a complete directory', async (t) => {
const f = await fixture(t);
await fs.writeFile(path.join(f.source, '.env.example'), 'QlPort=5700\n');
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
`exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};`,
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
const destination = path.join(f.directory, 'fresh'),
node = process.execPath;
await require('../../shell/install-bundle.cjs').installBundle(
['--root', destination, '--file', f.archive, '--install'],
{ ...process.env, PATH: '', QL_BRANCH: 'develop' },
);
assert.equal(process.execPath, node);
assert.equal(
await fs.readFile(path.join(destination, '.env'), 'utf8'),
'QlPort=5700\n',
);
assert.equal(
(await fs.stat(path.join(destination, '.env'))).mode & 0o777,
0o600,
);
assert.equal(
await fs.readFile(path.join(destination, 'static/build/app.js'), 'utf8'),
'backend',
);
assert.ok(
(
await fs.stat(path.join(destination, 'node_modules/sqlite3/package.json'))
).isFile(),
);
assert.equal(
(await fs.readdir(f.directory)).filter((n) =>
n.startsWith('.qinglong-install-'),
).length,
0,
);
await fs.writeFile(path.join(destination, 'keep-user-data'), 'keep');
await assert.rejects(
require('../../shell/install-bundle.cjs').installBundle([
'--root',
destination,
'--file',
f.archive,
'--install',
]),
/empty installation directory/,
);
assert.equal(
await fs.readFile(path.join(destination, 'keep-user-data'), 'utf8'),
'keep',
);
});
test('offline staging replaces stale panel readiness and refuses an active online job', async (t) => {
const f = await fixture(t);
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};',
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
await fs.mkdir(path.join(f.root, 'shell'));
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
'import_config() { :; }\n',
);
await fs.writeFile(
path.join(f.root, 'shell/env.sh'),
'export QL_BRANCH=develop\n',
);
const stateFile = path.join(f.tmp, 'panel-upgrade.json');
await fs.writeFile(
stateFile,
JSON.stringify({
id: 'previous-online-job',
status: 'ready',
phase: 'ready',
}),
);
const install = require('../../shell/install-bundle.cjs').installBundle;
await install(['--root', f.root, '--file', f.archive, '--download-only']);
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
assert.equal(state.status, 'ready');
assert.equal(state.sourceCommit, sourceCommit);
assert.notEqual(state.id, 'previous-online-job');
assert.equal(state.version, '2.24.0');
const pointer = await fs.readFile(
path.join(f.tmp, 'upgrade-ready-develop.json'),
'utf8',
);
await fs.writeFile(
stateFile,
JSON.stringify({
id: 'live-online-job',
status: 'running',
pid: process.pid,
startedAt: Date.now(),
}),
);
await assert.rejects(
install(['--root', f.root, '--file', f.archive, '--download-only']),
/已有更新/,
);
assert.equal(
JSON.parse(await fs.readFile(stateFile, 'utf8')).id,
'live-online-job',
);
assert.equal(
await fs.readFile(path.join(f.tmp, 'upgrade-ready-develop.json'), 'utf8'),
pointer,
);
});
test('interrupting offline staging records failure, preserves prior readiness and releases both locks', async (t) => {
const f = await fixture(t);
const { spawn } = require('node:child_process');
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
await fs.writeFile(
path.join(f.source, 'node_modules/sqlite3/index.js'),
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){setTimeout(()=>cb(null,{value:42}),10000)}close(){}};',
);
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
const dir = path.join(f.source, 'node_modules', name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
}
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
await fs.mkdir(path.join(f.root, 'shell'));
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
'import_config() { :; }\n',
);
await fs.writeFile(
path.join(f.root, 'shell/env.sh'),
'export QL_BRANCH=develop\n',
);
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
await fs.writeFile(pointer, 'previous-good-readiness');
const child = spawn(
process.execPath,
[
path.resolve(__dirname, '../../shell/install-bundle.cjs'),
'--root',
f.root,
'--file',
f.archive,
'--download-only',
],
{ stdio: 'ignore' },
);
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
const exited = new Promise((resolve, reject) => {
child.once('exit', resolve);
child.once('error', reject);
});
let seen = false;
for (let i = 0; i < 200; i++) {
const state = await fs
.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8')
.then(JSON.parse)
.catch(() => ({}));
if (state.phase === 'dependencies') {
seen = true;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
assert.ok(seen);
child.kill('SIGTERM');
assert.equal(await exited, 1);
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-good-readiness');
assert.equal(
JSON.parse(
await fs.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8'),
).status,
'failed',
);
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')));
await assert.rejects(
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
);
});
test('portable release archives preserve the permission manifest after normalizing image scripts', async (t) => {
const f = await fixture(t);
const script = path.join(f.source, 'sample/config.sample.sh');
await fs.chmod(script, 0o777);
const { normalizeModes } = require('../../scripts/build-upgrade-bundle.cjs');
await normalizeModes(f.source);
await normalizeModes(f.staticRoot);
assert.equal((await fs.stat(script)).mode & 0o777, 0o755);
f.manifest.files = await bundle.inventory({
source: f.source,
static: f.staticRoot,
});
await f.pack();
const extracted = path.join(f.directory, 'roundtrip');
await fs.mkdir(extracted);
await bundle.extractArchive(f.archive, extracted);
const selected = {
source: path.join(extracted, 'qinglong/source'),
static: path.join(extracted, 'qinglong/static'),
};
await bundle.verifyPayload(selected, f.manifest, f.target);
await fs.chmod(path.join(selected.source, 'sample/config.sample.sh'), 0o777);
await assert.rejects(
bundle.verifyPayload(selected, f.manifest, f.target),
/checksums/,
);
});
test('full dependency bundles get a bounded slow-link download budget instead of the old ten-minute limit', async (t) => {
const f = await fixture(t);
let observed;
await assert.rejects(
artifacts.downloadWithProgress(
async (program, args) => {
assert.equal(program, 'curl');
observed = Number(args[args.indexOf('--max-time') + 1]);
assert.ok(observed > 600 && observed <= 7200);
assert.equal(args[args.indexOf('--speed-limit') + 1], '1024');
throw new Error('stop before transfer');
},
path.join(f.directory, 'slow.tar.gz'),
'https://example.invalid/package.tar.gz',
'qinglong-bundle',
async () => {},
true,
{ size: 24 * 1024 * 1024, sha256: 'a'.repeat(64) },
),
/stop before transfer/,
);
assert.ok(observed);
});
+398
View File
@@ -0,0 +1,398 @@
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 {
sha256,
} = require('../../cli/src/internal/maintenance/platformBundle.cjs');
const {
platforms,
publishBundles,
} = require('../../scripts/publish-upgrade-bundles.cjs');
function fixture(t) {
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql-publish-bundles-'),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const state = {
releases: new Map(),
head: 'a'.repeat(40),
developHead: 'c'.repeat(40),
calls: [],
nextId: 1,
};
const options = {
directory,
commit: state.head,
repository: 'whyour/qinglong',
branch: 'master',
version: '2.24.0',
buildId: '100-1',
};
const write = (
commit = options.commit,
content = 'first',
version = options.version,
) => {
for (const target of platforms) {
const filename = `qinglong-${commit}-${target}.tar.gz`;
const bytes = Buffer.from(`${version}/${content}/${target}`);
fs.writeFileSync(path.join(directory, filename), bytes);
fs.writeFileSync(
path.join(directory, filename + '.json'),
JSON.stringify({
format: 2,
sourceCommit: commit,
version,
target,
filename,
size: bytes.length,
sha256: sha256(bytes),
}),
);
}
fs.writeFileSync(
path.join(directory, 'install-bundle.cjs'),
`// ${content} installer`,
);
};
const notFound = () => {
throw Object.assign(new Error('Not found'), {
stderr: 'gh: Not Found (HTTP 404)',
});
};
const gh = (...args) => {
state.calls.push(args);
if (args[0] === 'api') {
const route = args[1];
if (route.endsWith('/git/ref/heads/master')) return state.head;
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
if (route.includes('/releases/tags/')) {
const tag = decodeURIComponent(route.split('/releases/tags/')[1]);
if (state.lookupFailure === tag)
throw Object.assign(new Error('Unavailable'), { stderr: 'HTTP 503' });
const release = state.releases.get(tag);
if (!release) return notFound();
return JSON.stringify({
assets: Array.from(release.values(), (a) => ({
name: a.name,
id: a.id,
size: a.bytes.length,
digest: a.digest,
})),
});
}
if (route.includes('/releases/assets/')) {
const id = Number(route.split('/').at(-1));
for (const release of state.releases.values())
for (const asset of release.values())
if (asset.id === id) return asset.bytes.toString();
return notFound();
}
}
if (args[0] === 'release' && args[1] === 'create') {
assert.ok(!state.releases.has(args[2]));
state.releases.set(args[2], new Map());
return '';
}
if (args[0] === 'release' && args[1] === 'upload') {
const tag = args[2],
release = state.releases.get(tag);
assert.ok(release);
const files = args.slice(3, args.indexOf('--repo'));
for (let i = 0; i < files.length; i++) {
if (state.failBatchUpload && files.length > 1 && i === 4)
throw new Error('Upload interrupted');
const name = path.basename(files[i]),
bytes = fs.readFileSync(files[i]);
assert.ok(
!release.has(name) || args.includes('--clobber'),
'Batch assets must not be overwritten',
);
release.set(name, {
name,
id: state.nextId++,
bytes: Buffer.from(bytes),
digest: `sha256:${sha256(bytes)}`,
});
}
if (files.length > 1) {
if (state.corruptUploadedBatch)
release.get(path.basename(files[0])).digest = 'sha256:wrong';
if (state.changeHeadAfterUpload) state.head = 'b'.repeat(40);
}
return '';
}
if (args[0] === 'release' && args[1] === 'edit') return '';
throw new Error(`Unexpected gh call: ${JSON.stringify(args)}`);
};
const publish = (overrides = {}) =>
publishBundles({ ...options, gh, ...overrides });
const index = (tag = 'upgrade-master') =>
JSON.parse(state.releases.get(tag).get('latest.json').bytes);
write();
return { directory, state, options, write, publish, index };
}
test('publishes every platform and installer before advancing version and legacy master indexes', (t) => {
const f = fixture(t);
assert.equal(f.publish(), true);
const index = f.index();
assert.equal(index.version, '2.24.0');
assert.equal(index.buildId, '100-1');
assert.equal(index.sourceCommit, f.options.commit);
assert.equal(Object.keys(index.packages).length, 13);
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, 28);
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}/`));
const bytes = f.state.releases.get(tag).get(filename).bytes;
assert.equal(bytes.length, entry.size);
assert.equal(sha256(bytes), entry.sha256);
}
const writes = f.state.calls.filter(
(a) => a[0] === 'release' && a[1] === 'upload',
);
assert.deepEqual(
writes.map((a) => a[2]),
[tag, 'upgrade-v2.24.0', 'upgrade-master'],
);
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
});
for (const sameCommit of [false, true])
test(`same-version replacement updates both indexes and retains in-flight downloads (${
sameCommit ? 'same commit rerun' : 'new commit'
})`, (t) => {
const f = fixture(t);
f.publish();
const before = f.index();
const oldAssets = new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
);
const commit = sameCommit ? f.options.commit : 'b'.repeat(40);
f.state.head = commit;
f.write(commit, 'replacement');
assert.equal(f.publish({ commit, buildId: '100-2' }), true);
const after = f.index();
assert.equal(after.version, before.version);
assert.equal(after.buildId, '100-2');
assert.equal(after.sourceCommit, commit);
assert.deepEqual(after, f.index('upgrade-v2.24.0'));
for (const target of platforms) {
assert.notEqual(after.packages[target].url, before.packages[target].url);
assert.notEqual(
after.packages[target].sha256,
before.packages[target].sha256,
);
}
assert.notEqual(after.installer.url, before.installer.url);
assert.notEqual(after.installer.sha256, before.installer.sha256);
assert.deepEqual(
new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
),
oldAssets,
);
});
test('retrying a publication is idempotent, but changed bytes require a new build ID', (t) => {
const f = fixture(t);
f.publish();
const count = () =>
f.state.calls.filter(
(a) =>
a[0] === 'release' &&
a[1] === 'upload' &&
a[2] === 'upgrade-build-v2.24.0-100-1',
).length;
assert.equal(count(), 1);
f.publish();
assert.equal(count(), 1);
f.write(f.options.commit, 'changed within the same attempt');
assert.throws(() => f.publish(), /Immutable release asset differs/);
assert.equal(f.index().buildId, '100-1');
});
for (const fault of ['missing', 'corrupt', 'wrong-version'])
test(`incomplete, corrupt or wrong-version packages never publish (${fault})`, (t) => {
const f = fixture(t);
const archive = path.join(
f.directory,
`qinglong-${f.options.commit}-linux-armv6-glibc.tar.gz`,
);
if (fault === 'missing') fs.rmSync(archive);
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
else {
const record = JSON.parse(fs.readFileSync(archive + '.json'));
record.version = '2.23.0';
fs.writeFileSync(archive + '.json', JSON.stringify(record));
}
assert.throws(() => f.publish(), /ENOENT|Unverified/);
assert.equal(f.state.calls.length, 0);
});
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
test(`failed batch publication keeps both existing indexes (${fault})`, (t) => {
const f = fixture(t);
f.publish();
const previous = f.index();
f.write(f.options.commit, 'next');
f.state[fault] = true;
assert.throws(
() => f.publish({ buildId: '100-2' }),
/Upload interrupted|Published platform bundle verification failed/,
);
assert.deepEqual(f.index(), previous);
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
});
test('stale commits do not publish; a source change during upload cannot advance indexes', (t) => {
const f = fixture(t);
f.publish();
const before = f.index();
f.state.head = 'b'.repeat(40);
assert.equal(f.publish({ buildId: '101-1' }), false);
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1'));
f.state.head = f.options.commit;
f.state.changeHeadAfterUpload = true;
assert.equal(f.publish({ buildId: '102-1' }), false);
assert.deepEqual(f.index(), before);
assert.deepEqual(f.index('upgrade-v2.24.0'), before);
});
test('an explicitly rerun earlier workflow may replace the same version and commit', (t) => {
const f = fixture(t);
f.publish({ buildId: '101-1' });
const before = f.index();
f.write(f.options.commit, 'rebuild from an earlier run');
assert.equal(f.publish({ buildId: '100-2' }), true);
assert.equal(f.index().buildId, '100-2');
assert.deepEqual(f.index(), f.index('upgrade-v2.24.0'));
assert.notEqual(
f.index().packages[platforms[0]].sha256,
before.packages[platforms[0]].sha256,
);
});
test('non-master branches, invalid build IDs and API failures cannot overwrite published versions', (t) => {
const f = fixture(t);
for (const branch of ['develop', 'v2.24.0', 'feature/test'])
assert.throws(() => f.publish({ branch }), /Only master/);
for (const buildId of [undefined, '100', '100-1/other'])
assert.throws(() => f.publish({ buildId }), /Invalid bundle build ID/);
assert.throws(
() => f.publish({ version: '../bad' }),
/Invalid bundle version/,
);
assert.equal(f.state.calls.length, 0);
f.publish();
const before = f.index();
f.state.lookupFailure = 'upgrade-v2.24.0';
assert.throws(() => f.publish({ buildId: '100-2' }), /Unavailable/);
assert.deepEqual(f.index(), before);
});
test('existing master clients can migrate from indexes without version/build metadata', (t) => {
const f = fixture(t);
f.publish();
const asset = f.state.releases.get('upgrade-master').get('latest.json');
const legacy = f.index();
delete legacy.version;
delete legacy.buildId;
asset.bytes = Buffer.from(JSON.stringify(legacy));
assert.equal(f.publish({ buildId: '100-2' }), true);
assert.equal(f.index().buildId, '100-2');
});
test('explicit development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
f.write(f.state.developHead, 'development');
assert.equal(
f.publish({
branch: 'develop',
commit: f.state.developHead,
allowDevelopment: true,
}),
true,
);
const development = f.index('upgrade-develop');
assert.equal(development.channel, 'develop');
assert.equal(development.sourceCommit, f.state.developHead);
assert.equal(Object.keys(development.packages).length, 13);
assert.ok(development.packages['linux-x64-musl']);
assert.ok(development.packages['linux-x64-glibc']);
assert.ok(development.packages['linux-armv6-musl']);
assert.ok(development.packages['linux-armv6-glibc']);
for (const entry of [
...Object.values(development.packages),
development.installer,
])
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, 28);
assert.equal(
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
28,
);
});
test('development builds require explicit boolean opt-in and cannot publish obsolete development source', (t) => {
const f = fixture(t);
for (const allowDevelopment of [false, undefined, 'true'])
assert.throws(
() => f.publish({ branch: 'develop', allowDevelopment }),
/explicitly requested/,
);
assert.throws(
() => f.publish({ branch: 'feature/test', allowDevelopment: true }),
/Only master/,
);
assert.equal(f.state.calls.length, 0);
f.write(f.state.developHead, 'development');
const obsolete = f.state.developHead;
f.state.developHead = 'd'.repeat(40);
assert.equal(
f.publish({ branch: 'develop', commit: obsolete, allowDevelopment: true }),
false,
);
assert.equal(f.state.releases.size, 0);
});
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
test(`failed manual development publication preserves both channels (${fault})`, (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
const options = {
branch: 'develop',
commit: f.state.developHead,
allowDevelopment: true,
};
f.write(options.commit, 'first development');
f.publish(options);
const previous = f.index('upgrade-develop');
f.write(options.commit, 'next development');
f.state[fault] = true;
assert.throws(
() => f.publish({ ...options, buildId: '100-2' }),
/Upload interrupted|Published platform bundle verification failed/,
);
assert.deepEqual(f.index('upgrade-develop'), previous);
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
});
+30
View File
@@ -23,6 +23,9 @@ function fixture() {
let invalidations = 0;
const module = { exports: {} };
const localRequire = createRequire(path.resolve('back/app.ts'));
const middlewareRequire = createRequire(
path.resolve('back/loaders/httpApplication.ts'),
);
const mocks = {
'./config': { cors: { origin: ['http://localhost'] } },
'./loaders/logger': { error() {}, warn() {}, info() {} },
@@ -37,6 +40,33 @@ function fixture() {
new Function('require', 'module', 'exports', 'process', compiled)(
(name) => {
required.push(name);
if (name === './loaders/httpApplication') {
const helper = { exports: {} };
const code = ts.transpileModule(
fs.readFileSync('back/loaders/httpApplication.ts', 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
},
).outputText;
new Function('require', 'module', 'exports', code)(
(dependency) => {
required.push(dependency);
const alias = dependency.startsWith('../')
? './' + dependency.slice(3)
: dependency;
return Object.hasOwn(mocks, alias)
? mocks[alias]
: middlewareRequire(dependency);
},
helper,
helper.exports,
);
return helper.exports;
}
return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name);
},
module,
@@ -0,0 +1,53 @@
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 { spawnSync } = require('node:child_process');
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
const preparation = startup.slice(
startup.indexOf('\nfix_config\n'),
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
);
for (const fails of [false, true]) {
test(`manual startup ${
fails
? 'stops on certificate failure'
: 'awaits certificates before starting supervision'
}`, (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-start-certs-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const module = path.join(root, 'static/build/config/grpcCerts.js');
fs.mkdirSync(path.dirname(module), { recursive: true });
fs.writeFileSync(
module,
`exports.initGrpcCerts = () => new Promise((resolve, reject) => setTimeout(() => {
if (process.env.QA_CERT_FAILURE === '1') return reject(new Error('certificate preparation failed'));
require('node:fs').writeFileSync(require('node:path').join(process.env.QL_DIR, 'certs-ready'), 'ready');
resolve();
}, 50));`,
);
const result = spawnSync(
'/bin/bash',
[
'-c',
`set -e
fix_config(){ :; }
pm2(){ test -f "$QL_DIR/certs-ready"; touch "$QL_DIR/supervisor-started"; }
${preparation}`,
],
{
encoding: 'utf8',
env: {
...process.env,
QL_DIR: root,
QA_CERT_FAILURE: fails ? '1' : '0',
},
},
);
assert.equal(result.status, fails ? 1 : 0, result.stderr);
assert.equal(fs.existsSync(path.join(root, 'supervisor-started')), !fails);
if (fails) assert.match(result.stderr, /certificate preparation failed/);
});
}
+12 -1
View File
@@ -393,7 +393,10 @@ done
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
QL_MIRROR_NOTIFY_ON_START: 'false',
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
// Bash SECONDS has one-second resolution. Two ticks guarantee a real
// wait across a second boundary; a single tick may take only milliseconds.
QL_MIRROR_RETRY_AFTER_SECONDS: '2',
QL_MIRROR_WAIT_SECONDS: '8',
QL_TEST_COUNT: countFile,
QL_TEST_MIRROR: mirror,
},
@@ -403,6 +406,14 @@ done
Date.now() - started >= 900,
'notification should wait for the sync interval',
);
const retry = result.stdout.indexOf('Retrying the GitHub notification');
assert.ok(retry >= 0, result.stdout);
const elapsed = [
...result.stdout
.slice(0, retry)
.matchAll(/Waiting for mirror \(attempt \d+, (\d+)\/8s\)/g),
].at(-1);
assert.ok(elapsed && Number(elapsed[1]) >= 2, result.stdout);
assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n');
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
+109
View File
@@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const yaml = require('js-yaml');
const workflow = yaml.load(
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
);
// These conditions use GitHub's boolean operators and format(), which have the
// same semantics for the boolean/string contexts exercised here.
const evaluate = (expression, github, inputs = {}) => {
const run = (code) =>
vm.runInNewContext(code, {
github: { run_id: '100', run_attempt: '1', ...github },
inputs,
format: (template, ...values) =>
template.replace(/\{(\d+)\}/g, (_, i) => values[i]),
});
const exact = expression.match(/^\$\{\{([\s\S]*?)\}\}$/);
return exact
? run(exact[1])
: expression.replace(/\$\{\{([\s\S]*?)\}\}/g, (_, code) =>
String(run(code)),
);
};
test('both bundle jobs require master or an explicit development dispatch', () => {
assert.equal(
workflow.on.workflow_dispatch.inputs.build_development_bundles.default,
false,
);
const cases = [
['push', 'refs/heads/develop', {}, false],
['push', 'refs/heads/develop', { build_development_bundles: true }, false],
['workflow_dispatch', 'refs/heads/develop', {}, false],
[
'workflow_dispatch',
'refs/heads/develop',
{ build_development_bundles: true },
true,
],
[
'workflow_dispatch',
'refs/heads/develop',
{ build_development_bundles: true, static_sync_only: true },
false,
],
['push', 'refs/heads/master', {}, true],
['workflow_dispatch', 'refs/heads/master', {}, true],
[
'workflow_dispatch',
'refs/heads/master',
{ static_sync_only: true },
false,
],
['push', 'refs/tags/v2.24.0', {}, false],
[
'workflow_dispatch',
'refs/tags/v2.24.0',
{ build_development_bundles: true },
false,
],
];
for (const name of ['build-upgrade-bundles', 'publish-upgrade-bundles'])
for (const [event_name, ref, inputs, expected] of cases)
assert.equal(
Boolean(evaluate(workflow.jobs[name].if, { event_name, ref }, inputs)),
expected,
`${name}: ${event_name} ${ref} ${JSON.stringify(inputs)}`,
);
assert.deepEqual(workflow.jobs['build-upgrade-bundles'].needs, [
'build-alpine',
'build-debian',
]);
assert.ok(
workflow.jobs['build-upgrade-bundles'].steps.some(
(step) =>
step.env?.ALPINE_DIGEST ===
'${{ needs.build-alpine.outputs.image-digest }}' &&
step.env?.DEBIAN_DIGEST ===
'${{ needs.build-debian.outputs.image-digest }}',
),
);
});
test('manual development releases serialize separately while normal develop CI remains independent', () => {
const group = (event_name, ref, inputs = {}, run_id = '100') =>
evaluate(workflow.concurrency.group, { event_name, ref, run_id }, inputs);
const master = group('push', 'refs/heads/master');
const development = group('workflow_dispatch', 'refs/heads/develop', {
build_development_bundles: true,
});
assert.equal(
development,
group(
'workflow_dispatch',
'refs/heads/develop',
{ build_development_bundles: true },
'101',
),
);
assert.equal(master, group('push', 'refs/heads/master', {}, '101'));
assert.notEqual(master, development);
const push = group('push', 'refs/heads/develop');
assert.notEqual(push, development);
assert.notEqual(push, group('push', 'refs/heads/develop', {}, '101'));
assert.equal(workflow.concurrency['cancel-in-progress'], false);
});
+51
View File
@@ -283,3 +283,54 @@ test('unhealthy, malformed and oversized health responses fail within the deadli
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/);
}
});
test('ARMv6 startup can become healthy after the standard one-minute deadline', async (t) => {
const { EventEmitter } = require('node:events');
const descriptors = ['arch', 'config'].map((key) => [
key,
Object.getOwnPropertyDescriptor(process, key),
]);
t.after(() => {
for (const [key, descriptor] of descriptors)
Object.defineProperty(process, key, descriptor);
});
Object.defineProperty(process, 'arch', { value: 'arm', configurable: true });
Object.defineProperty(process, 'config', {
value: { variables: { arm_version: 6 } },
configurable: true,
});
let now = 0;
let probes = 0;
t.mock.method(Date, 'now', () => now);
t.mock.method(http, 'get', (_options, callback) => {
const request = new EventEmitter();
request.destroy = (error) => request.emit('error', error);
queueMicrotask(() => {
const response = new EventEmitter();
response.statusCode = ++probes === 1 ? 503 : 200;
callback(response);
now = probes * 70000;
response.emit(
'data',
JSON.stringify({
code: response.statusCode,
data: { status: probes > 1 ? 'ok' : 'starting' },
}),
);
response.emit('end');
});
return request;
});
await waitForHealth({});
assert.equal(probes, 2);
assert.equal(now, 140000);
const { startupHealthTimeout } = require('../../shell/upgrade.cjs');
assert.equal(
startupHealthTimeout({
arch: 'arm',
config: { variables: { arm_version: 7 } },
}),
60000,
);
assert.equal(startupHealthTimeout({ arch: 'arm64' }), 60000);
});
+43
View File
@@ -286,3 +286,46 @@ npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; }
);
assert.deepEqual(await fs.readdir(f.tmp), []);
});
test('offline selection and worker ownership do not leak into the restarted backend', async (t) => {
const f = await fixture(t);
await fs.writeFile(
path.join(f.root, 'shell/share.sh'),
`${await fs.readFile('shell/share.sh', 'utf8')}
pm2() { if [ "$1" = startOrGracefulReload ]; then node "$QL_DIR/report-runtime.cjs"; fi; }
`,
);
await fs.writeFile(
path.join(f.root, 'report-runtime.cjs'),
`
require('node:fs').writeFileSync(process.env.QL_DIR + '/runtime.json', JSON.stringify({
file: process.env.QL_UPGRADE_FILE ?? null,
repair: process.env.QL_UPGRADE_REPAIR_DEPS ?? null,
launcher: process.env.QL_UPGRADE_LAUNCHER_PID ?? null,
custom: process.env.QA_USER_SETTING,
branch: process.env.QL_BRANCH,
}));
`,
);
const env = Object.freeze({
...f.env,
QL_UPGRADE_FILE: path.join(f.root, 'offline.tar.gz'),
QL_UPGRADE_REPAIR_DEPS: '1',
QL_UPGRADE_LAUNCHER_PID: '12345',
QA_USER_SETTING: 'preserved',
QL_BRANCH: 'develop',
});
await withLifecycleHooks(f.root, f.tmp, env, (hook) => hook('start'));
assert.deepEqual(
JSON.parse(await fs.readFile(path.join(f.root, 'runtime.json'))),
{
file: null,
repair: null,
launcher: null,
custom: 'preserved',
branch: 'develop',
},
);
assert.equal(env.QL_UPGRADE_FILE, path.join(f.root, 'offline.tar.gz'));
assert.equal(env.QL_UPGRADE_REPAIR_DEPS, '1');
});
+350
View File
@@ -0,0 +1,350 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const {
prestartBackend,
dependencyFailure,
probeLimits,
repairDependencies,
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
for (const exitCode of [0, 1])
test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
statics = path.join(root, 'static'),
survivor = path.join(root, 'survivor');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
);
await fs.writeFile(
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
`
const fs=require('node:fs'),crypto=require('node:crypto');
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),600);`,
)}],{stdio:'inherit'});
process.send({type:'upgrade-probe',ok:${
exitCode === 0
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
`,
);
const operation = prestartBackend(
{ source, static: statics },
{ target: { arch: 'armv6' }, limits: { heapMb: 128, timeoutMs: 3000 } },
);
if (exitCode) await assert.rejects(operation, /simulated prestart failure/);
else await operation;
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
await new Promise((r) => setTimeout(r, 700));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
for (const phase of ['install', 'rebuild'])
test(`dependency repair ${phase} failure preserves original dependencies, cleans workspace and uses configured proxy`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-failure-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
const proxy = path.join(root, 'network.json');
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);}
fs.writeFileSync(${JSON.stringify(
proxy,
)},JSON.stringify({https:process.env.HTTPS_PROXY,no:process.env.no_proxy,options:process.env.NODE_OPTIONS}));
fs.mkdirSync('node_modules',{recursive:true});fs.writeFileSync('node_modules/partial','partial');
if(process.argv[2]===${JSON.stringify(
phase,
)}) {console.error('simulated repair failure');process.exit(12);}
`,
{ mode: 0o755 },
);
await assert.rejects(
repairDependencies(source, {
target: { arch: 'armv6' },
env: {
PATH: bin,
HTTPS_PROXY: 'http://127.0.0.1:7890',
no_proxy: 'localhost,127.0.0.1',
NODE_OPTIONS: '--require untrusted',
},
}),
/simulated repair failure/,
);
assert.equal(
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
'preserved',
);
assert.deepEqual(await fs.readdir(path.join(source, 'node_modules')), [
'old',
]);
assert.deepEqual((await fs.readdir(root)).sort(), [
'bin',
'network.json',
'source',
]);
const network = JSON.parse(await fs.readFile(proxy));
assert.equal(network.https, 'http://127.0.0.1:7890');
assert.equal(network.no, 'localhost,127.0.0.1');
assert.doesNotMatch(network.options, /untrusted/);
});
test('cancelled dependency repair kills descendants and retains the original tree', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-cancel-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin'),
started = path.join(root, 'started'),
survivor = path.join(root, 'survivor');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
);
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}
if(process.argv[2]==='--version'){console.log('8.3.1');process.exit(0);}
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`process.on('SIGTERM',()=>{});require('node:fs').writeFileSync(${JSON.stringify(
started,
)},'started');setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),1400);`,
)}],{stdio:'inherit'});
setInterval(()=>{},1000);
`,
{ mode: 0o755 },
);
const controller = new AbortController();
const operation = repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: bin },
signal: controller.signal,
});
const rejection = assert.rejects(operation, /cancel repair/);
let seen = false;
for (let i = 0; i < 150; i++) {
if (await fs.stat(started).catch(() => null)) {
seen = true;
break;
}
await new Promise((r) => setTimeout(r, 20));
}
controller.abort(new Error('cancel repair'));
await rejection;
assert.ok(seen);
assert.equal(
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
'preserved',
);
assert.deepEqual((await fs.readdir(root)).sort(), [
'bin',
'source',
'started',
]);
await new Promise((r) => setTimeout(r, 1500));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
test('real Node resolution failures distinguish broken dependency entries and internal imports from backend source', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-missing-entry-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const directory = path.join(root, 'node_modules/broken-entry');
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(
path.join(directory, 'package.json'),
JSON.stringify({ main: 'missing.js' }),
);
const load = require('node:module').createRequire(
path.join(root, 'package.json'),
);
assert.throws(
() => load('broken-entry'),
(error) => dependencyFailure(error),
);
await fs.writeFile(
path.join(directory, 'missing.js'),
"require('./missing-internal-file')",
);
assert.throws(
() => load('broken-entry'),
(error) => dependencyFailure(error),
);
await fs.writeFile(
path.join(root, 'api.js'),
"require('./missing-api-route')",
);
assert.throws(
() => load('./api.js'),
(error) => !dependencyFailure(error),
);
});
test('ARMv6 repair isolates configuration, uses frozen install, one compiler job and interpreted Node', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-armv6-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
bin = path.join(root, 'bin');
await fs.mkdir(source);
await fs.mkdir(bin);
await fs.writeFile(
path.join(source, 'package.json'),
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
);
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.error('harmless runtime warning'); console.log('8.3.1');}
else if(process.argv[2]==='install') {
if(!process.argv.includes('--frozen-lockfile')||!process.argv.includes('--prod')) throw Error('not frozen production install');
fs.mkdirSync('node_modules');fs.writeFileSync('node_modules/flags.json',JSON.stringify({
args:process.argv,options:process.env.NODE_OPTIONS,source:process.env.npm_config_build_from_source,
processConfig:process.env.npm_config_force_process_config,jobs:process.env.npm_config_jobs,make:process.env.MAKEFLAGS
}));
} else if(process.argv.slice(2).join(' ')!=='rebuild sqlite3 @whyour/sqlite3') throw Error('unexpected command');
`,
{ mode: 0o755 },
);
await repairDependencies(source, {
target: { arch: 'armv6' },
env: {
PATH: bin,
NODE_OPTIONS: '--require untrusted',
npm_config_nodedir: '/wrong-node',
},
});
const flags = JSON.parse(
await fs.readFile(path.join(source, 'node_modules/flags.json')),
);
assert.match(flags.options, /--jitless/);
assert.match(flags.options, /max-old-space-size=256/);
assert.equal(flags.source, 'true');
assert.equal(flags.processConfig, 'true');
assert.equal(flags.jobs, '1');
assert.equal(flags.make, '-j1');
assert.deepEqual((await fs.readdir(root)).sort(), ['bin', 'source']);
});
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'" },
{ message: 'Bundle platform mismatch' },
{ message: 'SQLITE_CANTOPEN' },
{ message: 'Upgrade prestart timed out' },
{
code: 'ERR_DLOPEN_FAILED',
message: 'node_modules/sqlite3/node.node',
signal: 'SIGKILL',
},
{ message: 'JavaScript heap out of memory' },
])
assert.equal(dependencyFailure(error), false, JSON.stringify(error));
assert.equal(
dependencyFailure({
code: 'MODULE_NOT_FOUND',
message: "Cannot find module 'decimal.js'",
}),
true,
);
assert.equal(
dependencyFailure({
code: 'ERR_DLOPEN_FAILED',
message: 'dlopen /tmp/node_modules/sqlite3/node.node: invalid ELF header',
}),
true,
);
assert.equal(
dependencyFailure({
message:
'Could not locate the bindings file /tmp/node_modules/sqlite3/bindings',
}),
true,
);
assert.deepEqual(probeLimits({ arch: 'armv6' }), {
heapMb: 128,
timeoutMs: 300000,
});
});
test('prestart deadline kills its process group and removes temporary data', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-prestart-timeout-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
statics = path.join(root, 'static');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
);
const survivor = path.join(root, 'survivor');
await fs.writeFile(
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
`
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
survivor,
)},'escaped'),2200)`,
)}],{stdio:'ignore'});
setInterval(()=>{},1000);
`,
);
await assert.rejects(
prestartBackend(
{ source, static: statics },
{
target: { arch: 'armv6' },
limits: { heapMb: 128, timeoutMs: 1000 },
},
),
/timed out/,
);
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
await new Promise((resolve) => setTimeout(resolve, 1600));
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
});
test('repair refuses an unsupported package manager before invoking a network command', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-lock-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source');
await fs.mkdir(source);
await fs.writeFile(path.join(source, 'package.json'), '{}');
await assert.rejects(
repairDependencies(source, {
target: { arch: 'armv6' },
env: { PATH: '' },
}),
/Unsupported frozen-lockfile/,
);
assert.deepEqual(await fs.readdir(root), ['source']);
});
+106
View File
@@ -0,0 +1,106 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
function layout(width, legacy = false) {
const values = [],
listeners = new Set(),
attributes = new Map(),
effects = [];
let cursor = 0,
mounted = false;
const media = { matches: width < 768 };
const add = (fn) => listeners.add(fn),
remove = (fn) => listeners.delete(fn);
Object.assign(
media,
legacy
? { addListener: add, removeListener: remove }
: {
addEventListener: (_, fn) => add(fn),
removeEventListener: (_, fn) => remove(fn),
},
);
const module = { exports: {} };
const source = ts.transpileModule(
fs.readFileSync(path.join(__dirname, '../../src/utils/hooks.ts'), 'utf8'),
{
compilerOptions: {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
},
},
).outputText;
new Function('require', 'module', 'exports', 'window', 'document', source)(
(name) => {
assert.equal(name, 'react');
return {
useState: (initial) => {
const index = cursor++;
if (!(index in values))
values[index] = typeof initial === 'function' ? initial() : initial;
return [
values[index],
(value) => {
values[index] = value;
},
];
},
useEffect: (fn) => {
if (!mounted) effects.push(fn);
},
};
},
module,
module.exports,
{ matchMedia: () => media, navigator: { userAgent: 'Desktop browser' } },
{ body: { setAttribute: (key, value) => attributes.set(key, value) } },
);
const render = () => {
cursor = 0;
return module.exports.useCtx();
};
const initial = render();
const cleanup = effects.map((fn) => fn());
mounted = true;
return {
initial,
attributes,
listeners,
resize: (width) => {
media.matches = width < 768;
listeners.forEach((fn) => fn());
return render();
},
unmount: () => cleanup.forEach((fn) => fn?.()),
};
}
test('narrow desktop windows retain the top navigation and user controls, and follow viewport changes', () => {
const view = layout(499);
assert.equal(view.initial.isPhone, true);
assert.equal(view.initial.headerStyle.marginTop, 0);
assert.equal(view.attributes.get('data-mode'), 'phone');
assert.equal(view.listeners.size, 1);
const desktop = view.resize(1024);
assert.equal(desktop.isPhone, false);
assert.equal(desktop.headerStyle.marginTop, -48);
assert.equal(view.attributes.get('data-mode'), 'desktop');
const narrow = view.resize(767.5);
assert.equal(narrow.isPhone, true);
assert.equal(narrow.headerStyle.marginTop, 0);
assert.equal(view.attributes.get('data-mode'), 'phone');
assert.equal(view.resize(768).isPhone, false);
view.unmount();
assert.equal(view.listeners.size, 0);
});
test('older media query listeners are removed when the layout unmounts', () => {
const view = layout(1024, true);
assert.equal(view.resize(600).isPhone, true);
assert.equal(view.attributes.get('data-mode'), 'phone');
view.unmount();
assert.equal(view.listeners.size, 0);
});
+36 -3
View File
@@ -335,11 +335,15 @@ test('the persistent yellow sidebar entry shows live progress and remains access
assert.match(p.nodes('button')[0].props['aria-label'], /62%/);
p.nodes('button')[0].props.onClick();
p.render({ collapsed: true });
assert.match(p.nodes('button')[0].props.className, /collapsed/);
assert.equal(p.nodes('button').length, 0);
assert.equal(p.nodes(p.Modal)[0].props.open, true);
f.store.setJob({ status: 'ready', phase: 'ready' });
f.store.setJob({ status: 'ready', phase: 'completed' });
p.render();
assert.equal(p.nodes('button')[0].props['aria-label'], '更新已就绪');
assert.equal(p.nodes('button').length, 0);
p.nodes(p.Modal)[0].props.onCancel();
p.render({ collapsed: true });
assert.match(p.nodes('button')[0].props.className, /collapsed/);
assert.equal(p.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
});
test('completed update refreshes once and reminds once after reconnecting the refreshed page', async () => {
@@ -433,3 +437,32 @@ test('update modal uses plain aligned content and explains restart state', () =>
),
);
});
test('sidebar and dialog use the same authoritative status even when old phase fields disagree', () => {
const f = fixture(),
notice = f.component('src/components/UpgradeNotice.tsx'),
panel = f.component('src/pages/setting/checkUpdate.tsx');
f.store.setJob({ status: 'ready', phase: 'completed' });
notice.render();
panel.render();
assert.equal(
notice.nodes('button')[0].props['aria-label'],
f.hooks.upgradeStatusLabel(f.store.getSnapshot().job),
);
assert.ok(
panel
.nodes('Text')
.some((node) => node.props.children.includes?.('下载完成,等待重启')),
);
f.store.setJob({ status: 'ready', phase: 'downloading', percent: 62 });
notice.render();
assert.equal(notice.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
f.store.setJob({ status: 'completed', phase: 'ready' });
assert.equal(notice.render(), null);
panel.render();
assert.ok(
panel
.nodes('Text')
.some((node) => node.props.children.includes?.('升级成功')),
);
});
+134
View File
@@ -0,0 +1,134 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { createHash } = require('node:crypto');
const { writeManifest, sourceCommit } = require('./upgrade-fixture.cjs');
async function manualUpgradeFixture(t, wrongVersion = false) {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-job-')),
);
const tmp = path.join(root, '.tmp');
const workspace = path.join(tmp, 'upgrade-manual');
const selected = {
source: path.join(workspace, 'qinglong-develop'),
static: path.join(workspace, 'qinglong-static-develop'),
};
for (const dir of [
path.join(root, 'static/build'),
path.join(root, 'shell'),
path.join(root, 'data/config'),
path.join(root, 'node_modules'),
path.join(selected.source, 'sample'),
path.join(selected.source, 'node_modules'),
path.join(selected.static, 'build'),
])
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(root, 'static/build/app.js'), 'old backend');
await fs.writeFile(path.join(root, '.env'), 'preserve configuration');
await fs.writeFile(path.join(root, 'data/keep'), 'preserve data');
await fs.writeFile(path.join(root, 'node_modules/old'), 'old dependency');
await fs.writeFile(
path.join(root, 'shell/share.sh'),
'import_config(){ :; }\ndelete_pm2(){ :; }\nreload_pm2(){ :; }\n',
);
await fs.writeFile(path.join(root, 'shell/env.sh'), '');
await fs.writeFile(
path.join(selected.source, 'sample/config.sample.sh'),
'# sample',
);
await fs.writeFile(
path.join(selected.source, 'package.json'),
'{"version":"2.24.0"}',
);
await fs.writeFile(
path.join(selected.source, 'version.yaml'),
'version: 2.24.0\n',
);
await fs.writeFile(path.join(selected.static, 'build/app.js'), 'new backend');
writeManifest(selected.source, selected.static);
const manifest = JSON.parse(
await fs.readFile(path.join(selected.static, 'build-info.json')),
);
const expected = {
version: '2.24.0',
sourceCommit,
backendSha256: manifest.files['build/app.js'],
stageDirectory: 'upgrade-manual',
};
await fs.writeFile(
path.join(workspace, 'ready.json'),
JSON.stringify({
...selected,
root,
sourceCommit,
dependenciesChanged: true,
}),
);
await fs.writeFile(
path.join(tmp, 'upgrade-ready-develop.json'),
'{"directory":"upgrade-manual"}',
);
await fs.writeFile(
path.join(tmp, 'panel-upgrade.json'),
JSON.stringify({
id: 'offline-prepared',
action: 'download',
status: 'ready',
phase: 'ready',
...expected,
}),
);
const server = http.createServer(async (_request, response) => {
const content = await fs
.readFile(path.join(root, 'static/build/app.js'), 'utf8')
.catch(() => 'stopped');
const updated = content === 'new backend';
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
code: 200,
data: {
status: 'ok',
services: { http: true, grpc: true },
build: {
version: updated ? '2.24.0' : '2.23.0',
sourceCommit:
updated && !wrongVersion ? sourceCommit : 'b'.repeat(40),
backendSha256: createHash('sha256').update(content).digest('hex'),
instanceId: updated ? 'new-instance' : 'old-instance',
parentPid: updated ? 222 : 111,
},
},
}),
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const env = {
...process.env,
QL_DIR: root,
QL_BRANCH: 'develop',
dir_tmp: tmp,
dir_static: path.join(root, 'static'),
dir_data: path.join(root, 'data'),
dir_config: path.join(root, 'data/config'),
QlPort: String(server.address().port),
QlBaseUrl: '/',
};
return {
root,
tmp,
selected,
env,
expected,
state: async () =>
JSON.parse(await fs.readFile(path.join(tmp, 'panel-upgrade.json'))),
};
}
module.exports = { manualUpgradeFixture };