Compare commits

...
Author SHA1 Message Date
whyour 2cd5e0b06c fix: verify staged upgrades in the background before restarting 2026-10-18 05:30:21 +08:00
whyour a8d09a3c14 fix: wait for slow ARMv6 scheduler workers to become ready 2026-10-18 04:34:51 +08:00
whyour cb87c619bf fix: allow ARMv6 upgrades enough time to become healthy 2026-10-18 04:21:15 +08:00
whyour f8ffa48c32 fix: prepare manual-install certificates before starting workers 2026-10-18 03:01:33 +08:00
whyour c63ff6598e fix: probe production dependencies with the normal Node runtime 2026-10-18 02:51:46 +08:00
whyour 0a96118f28 ci: isolate ARMv6 bootstrap services and use the gzip Node SDK 2026-10-18 02:23:07 +08:00
whyour f911876d5c fix: use a modern pinned tracer for ARMv6 validation 2026-10-18 02:12:07 +08:00
whyour 047fdcbaba fix: include the data directory in offline startup instructions 2026-10-18 02:00:15 +08:00
whyour 27cebdc770 ci: keep ARMv6 validation images in the existing registry package 2026-10-18 01:53:56 +08:00
whyour 24b8be04c9 feat: support ARMv6 glibc upgrade bundles 2026-10-18 01:50:59 +08:00
whyour c8df528123 fix: validate extra schedules and localize validation errors
Reject invalid extra_schedules with HTTP 400 before create or update reaches the task service. Preserve omitted, null and empty extras, supported cron syntax, and additional metadata.

Localize missing rules, invalid string and array types, and non-object extra entries through the existing per-request translation middleware. Cover POST/PUT rejection without mutations, dynamic English/Chinese switching, and stable error paths.

Validation: 9 related regression tests passed on current develop; backend build passed. The API validation fix also passed full regression and isolated develop/debian-dev acceptance.

Refs #3099
2026-10-18 01:08:36 +08:00
whyour eac7a47f30 fix: run ARMv6 archive workers without V8 JIT and verify offline installation 2026-10-17 23:58:52 +08:00
whyour 71a13a1779 fix: allow bounded slow-link transfers for full upgrade bundles 2026-10-17 22:57:11 +08:00
whyour 4040747936 fix: declare isolated CLI archive build dependency and label full downloads 2026-10-17 22:47:56 +08:00
whyour 035fe8c5d2 fix: verify archived permissions and coordinate offline upgrade state 2026-10-17 22:42:25 +08:00
whyour aa150f051e feat: ship verified platform upgrade bundles with production dependencies 2026-10-17 20:51:07 +08:00
33 changed files with 2681 additions and 33 deletions
+98
View File
@@ -8,6 +8,8 @@ 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/panelUpgrade.cjs"
- "!.github/workflows/cli-package.yml"
branches:
- "master"
@@ -203,6 +205,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 +272,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 +294,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 +360,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 +571,93 @@ jobs:
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
build-upgrade-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:
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 }}
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
+9 -2
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
+3
View File
@@ -212,6 +212,9 @@ const messages: Record<string, Record<string, string>> = {
日志设置为忽略: 'Log set to ignore',
定时规则不能为空: 'Schedule rule cannot be empty',
无效的定时规则: 'Invalid schedule rule',
定时规则必须是字符串: 'Schedule rule must be a string',
额外定时规则必须是对象: 'Extra schedule must be an object',
额外定时规则必须是数组: 'Extra schedules must be an array',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
日志名称不能超过100个字符: 'Log name cannot exceed 100 characters',
+23 -1
View File
@@ -20,8 +20,10 @@ export const scheduleSchema = Joi.string()
.required()
.custom(validateSchedule)
.messages({
'any.required': '定时规则不能为空',
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
'string.base': '定时规则必须是字符串',
});
export const commonCronSchema = {
@@ -30,7 +32,27 @@ export const commonCronSchema = {
schedule: scheduleSchema,
labels: Joi.array().optional().allow(null),
sub_id: Joi.number().optional().allow(null),
extra_schedules: Joi.array().optional().allow(null),
extra_schedules: Joi.array()
.items(
Joi.object({
schedule: Joi.string()
.required()
.custom((value, helpers) =>
isValidCronSchedule(value) ? value : helpers.error('any.invalid'),
)
.messages({
'any.required': '定时规则不能为空',
'any.invalid': '无效的定时规则',
'string.empty': '定时规则不能为空',
'string.base': '定时规则必须是字符串',
}),
})
.unknown(true)
.messages({ 'object.base': '额外定时规则必须是对象' }),
)
.optional()
.allow(null)
.messages({ 'array.base': '额外定时规则必须是数组' }),
task_before: Joi.string().optional().allow('').allow(null),
task_after: Joi.string().optional().allow('').allow(null),
log_name: Joi.string()
+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": {
+11
View File
@@ -17,6 +17,17 @@ fs.copyFileSync(
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.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'),
+21 -5
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');
@@ -220,12 +221,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 +393,7 @@ async function runJob({ root, tmp, env = process.env }, id) {
);
await artifacts.waitForHealth(
env,
60000,
undefined,
recover ? undefined : controller.signal,
);
if (!recover)
@@ -429,16 +432,29 @@ 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,
};
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], {
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,480 @@
// 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 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,
}) {
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 });
await probe(selected.source, signal);
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;
}
}
async function verifyStagedBundle(selected) {
const manifest = JSON.parse(
await fs.readFile(
path.join(path.dirname(selected.source), 'bundle.json'),
'utf8',
),
);
await verifyPayload(selected, manifest, await runtimeTarget());
await smokeDependencies(selected.source);
}
module.exports = {
nodeRuntimeArgs,
atLeast,
runtimeTarget,
targetKey,
assertCompatible,
inventory,
verifyPayload,
smokeDependencies,
extractArchive,
stageBundle,
verifyStagedBundle,
sha256,
};
@@ -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 {
@@ -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}`;
@@ -828,7 +872,7 @@ async function main() {
await hook('start', [], recover ? undefined : controller.signal);
await waitForHealth(
env,
60000,
undefined,
recover ? undefined : controller.signal,
);
},
@@ -875,6 +919,7 @@ module.exports = {
selectedUpgrade,
replaceAndReload,
reloadSystem,
startupHealthTimeout,
waitForHealth,
runProcess,
withLifecycleHooks,
+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}-`));
});
+45
View File
@@ -0,0 +1,45 @@
# 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 '#!/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,51 @@
# 平台完整升级包
CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。
## 在线升级
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-<commit>` release 资产 URL。13 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。
## 手动离线安装和升级
从同一个 `upgrade-<commit>` release 下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`。独立安装器已内置解析依赖,在目标设备无需 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
```
安装到空目录:
```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 秒,超时或版本校验失败仍执行回退。依赖加载和 SQLite 读写仍由现有 Node 的正常模式独立进程验证,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。
- 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');
+141
View File
@@ -0,0 +1,141 @@
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,
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;
},
);
+153
View File
@@ -0,0 +1,153 @@
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) {
if (!/^[a-f0-9]{40}$/.test(commit) || !/^[\w.-]+\/[\w.-]+$/.test(repository))
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 ||
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/upgrade-${commit}/${filename}`,
};
}
return { format: 2, sourceCommit: commit, packages };
}
module.exports = { releaseIndex, platforms };
if (require.main === module) {
const [directory, commit, repository, branch] = process.argv.slice(2);
const index = releaseIndex(directory, commit, repository);
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/upgrade-${commit}/install-bundle.cjs`,
};
if (!['develop', 'master'].includes(branch))
throw new Error('Invalid release channel');
fs.writeFileSync(
path.join(directory, 'latest.json'),
JSON.stringify(index) + '\n',
);
const gh = (...args) =>
execFileSync('gh', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const tag = `upgrade-${commit}`;
try {
gh('release', 'view', tag, '--repo', repository);
} catch {
gh(
'release',
'create',
tag,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--title',
`Platform upgrade ${commit.slice(0, 12)}`,
'--notes',
'Verified platform bundles with production dependencies. Existing Node runtime is retained.',
);
}
const assets = fs
.readdirSync(directory)
.filter((n) => /\.(?:tar\.gz|cjs|json)$/.test(n) && n !== 'latest.json')
.map((n) => path.join(directory, n));
const published = JSON.parse(
gh('api', `repos/${repository}/releases/tags/${tag}`),
);
const missing = [];
for (const file of [...assets, path.join(directory, 'latest.json')]) {
const bytes = fs.readFileSync(file);
const previous = published.assets.find(
(a) => a.name === path.basename(file),
);
if (!previous) missing.push(file);
else if (
previous.size !== bytes.length ||
previous.digest !== `sha256:${sha256(bytes)}`
)
throw new Error(
`Immutable release asset differs: ${path.basename(file)}`,
);
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
// An older, slower run may publish immutable assets, but must not regress a channel.
if (
gh(
'api',
`repos/${repository}/git/ref/heads/${branch}`,
'--jq',
'.object.sha',
).trim() !== commit
) {
console.log('Newer source exists; leaving channel unchanged');
} else {
const channel = `upgrade-${branch}`;
try {
gh('release', 'view', channel, '--repo', repository);
} catch {
gh(
'release',
'create',
channel,
'--repo',
repository,
'--target',
commit,
'--prerelease',
'--title',
`Upgrade channel ${branch}`,
'--notes',
'The latest.json asset selects immutable, fully verified platform bundles.',
);
}
gh(
'release',
'upload',
channel,
path.join(directory, 'latest.json'),
'--repo',
repository,
'--clobber',
);
}
}
+314
View File
@@ -0,0 +1,314 @@
#!/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' },
},
});
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]',
);
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),
};
}
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 () => {
await report({
status: 'restarting',
phase: 'restarting',
});
await hook('stop', [], 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...')"
+1
View File
@@ -744,6 +744,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",
+1
View File
@@ -744,6 +744,7 @@
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
"下载构建产物": "下载构建产物",
"下载更新包": "下载更新包",
"下载源码": "下载源码",
"正在获取下载大小": "正在获取下载大小",
"查看更新日志": "查看更新日志",
+3 -1
View File
@@ -167,7 +167,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'
? '下载构建产物'
: '下载源码',
)}
+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',
]);
});
}
+124
View File
@@ -43,3 +43,127 @@ test('cron validation identifies invalid fields', () => {
['allow_multiple_instances'],
);
});
test('extra schedules preserve optional values, compatible syntax and metadata', () => {
for (const extra_schedules of [
undefined,
null,
[],
[{ schedule: '0 6/6 * * *', remarks: 'legacy metadata' }],
[{ schedule: '0 0 * JAN MON#2' }, { schedule: '@hourly' }],
]) {
const result = schema.validate({
command: 'task valid.js',
schedule: '01 7 * * *',
extra_schedules,
});
assert.equal(result.error, undefined, JSON.stringify(extra_schedules));
assert.deepEqual(result.value.extra_schedules, extra_schedules);
}
for (const schedule of ['@boot', '@once 1792245000']) {
assert.equal(
schema.validate({ command: 'task valid.js', schedule }).error,
undefined,
);
}
});
test('create and update reject invalid extra schedules before calling the service', async (t) => {
const express = require('express');
const { errors } = require('celebrate');
const load = require('../helpers/load-security-module.cjs');
const mutations = [];
const service = {
async create(value) {
mutations.push(value);
return { id: 1, ...value };
},
async update(value) {
mutations.push(value);
return value;
},
};
const route = load('back/api/cron.ts', {
typedi: {
Container: {
get: (key) => (key === 'logger' ? { error() {} } : service),
},
},
'../services/cron': class {},
'../services/cronView': class {},
'../validation/schedule': { commonCronSchema },
'../data/runningInstance': {},
'../shared/i18n': { t: (value) => value },
'../schedule/client': { readiness: { async ensureReady() {} } },
});
const app = express();
app.use(express.json());
route.default(app);
app.use(errors());
app.use((error, _req, res, _next) =>
res.status(error.status || 500).json({ message: error.message }),
);
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
for (const method of ['POST', 'PUT']) {
const payload = {
command: 'task valid.js',
schedule: '01 7 * * *',
...(method === 'PUT' ? { id: 1 } : {}),
};
const request = (body) =>
fetch(`http://127.0.0.1:${server.address().port}/crons`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
for (const extra_schedules of [
[{ schedule: '60 * * * *' }],
[{ schedule: '0 0 31 2,4 *' }],
[{ schedule: '' }],
[{ schedule: 12 }],
[{ schedule: '@boot' }],
[{ schedule: '@once 1792245000' }],
[{}],
[null],
['0 * * * *'],
[{ schedule: '0 * * * *' }, { schedule: 'invalid' }],
]) {
const before = mutations.length;
const response = await request({ ...payload, extra_schedules });
assert.equal(
response.status,
400,
`${method}: ${JSON.stringify(extra_schedules)}`,
);
const body = await response.json();
assert.ok(
body.validation.body.keys.some((key) =>
key.startsWith('extra_schedules.'),
),
);
assert.equal(
mutations.length,
before,
'invalid input must not mutate tasks',
);
}
assert.equal(
(await request({ ...payload, schedule: '60 * * * *' })).status,
400,
);
const extra_schedules = [{ schedule: '0 6/6 * * *', remarks: 'keep' }];
const response = await request({ ...payload, extra_schedules });
assert.equal(response.status, 200);
assert.deepEqual(
(await response.json()).data.extra_schedules,
extra_schedules,
);
}
assert.equal(mutations.length, 2);
});
+96 -2
View File
@@ -22,7 +22,7 @@ const {
} = require('../../back/validation/schedule');
const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
function responseFor(schema, value) {
function responseFor(schema, value, fullResponse = false) {
const validation = schema.validate(value).error;
assert.ok(validation);
const error = new CelebrateError('Validation failed', { celebrated: true });
@@ -41,7 +41,7 @@ function responseFor(schema, value) {
validationErrors(error, {}, res, (err) => {
throw err;
});
return response.validation.body.message;
return fullResponse ? response : response.validation.body.message;
}
test('compiled validation schemas follow live language changes without altering field paths', () => {
@@ -75,6 +75,100 @@ test('compiled validation schemas follow live language changes without altering
setLang('zh');
});
test('cron structure errors switch languages while retaining status and field paths', (t) => {
const schema = Joi.object(commonCronSchema);
const empty = ['定时规则不能为空', 'Schedule rule cannot be empty'];
const string = ['定时规则必须是字符串', 'Schedule rule must be a string'];
const object = ['额外定时规则必须是对象', 'Extra schedule must be an object'];
const array = ['额外定时规则必须是数组', 'Extra schedules must be an array'];
const invalid = ['无效的定时规则', 'Invalid schedule rule'];
const cases = [
[{ schedule: undefined }, 'schedule', empty],
[{ schedule: '' }, 'schedule', empty],
[{ schedule: 12 }, 'schedule', string],
[{ schedule: null }, 'schedule', string],
[{ extra_schedules: [{}] }, 'extra_schedules.0.schedule', empty],
[
{ extra_schedules: [{ schedule: '' }] },
'extra_schedules.0.schedule',
empty,
],
[
{ extra_schedules: [{ schedule: 12 }] },
'extra_schedules.0.schedule',
string,
],
[
{ extra_schedules: [{ schedule: null }] },
'extra_schedules.0.schedule',
string,
],
[
{ extra_schedules: [{ schedule: 'invalid' }] },
'extra_schedules.0.schedule',
invalid,
],
[
{ extra_schedules: [{ schedule: '0 * * * *' }, {}] },
'extra_schedules.1.schedule',
empty,
],
...[null, '0 * * * *', 12, []].map((item) => [
{ extra_schedules: [item] },
'extra_schedules.0',
object,
]),
...['0 * * * *', {}, 12].map((value) => [
{ extra_schedules: value },
'extra_schedules',
array,
]),
];
t.after(() => setLang('zh', false));
for (const lang of ['en', 'zh', 'en']) {
setLang(lang, false);
for (const [value, field, messages] of cases) {
const response = responseFor(
schema,
{
command: 'task valid.js',
schedule: '0 * * * *',
...value,
},
true,
);
assert.equal(response.statusCode, 400);
assert.equal(
response.validation.body.message,
messages[lang === 'zh' ? 0 : 1],
`${lang}: ${field}`,
);
assert.deepEqual(response.validation.body.keys, [field]);
}
const multiple = responseFor(
schema.prefs({ abortEarly: false }),
{
command: 'task valid.js',
schedule: '0 * * * *',
extra_schedules: [{}, { schedule: 12 }, null],
},
true,
);
assert.equal(multiple.statusCode, 400);
assert.equal(
multiple.validation.body.message,
[empty, string, object]
.map((messages) => messages[lang === 'zh' ? 0 : 1])
.join('. '),
);
assert.deepEqual(multiple.validation.body.keys, [
'extra_schedules.0.schedule',
'extra_schedules.1.schedule',
'extra_schedules.2',
]);
}
});
test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => {
const keys = [
'请先初始化',
+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);
});
+623
View File
@@ -0,0 +1,623 @@
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');
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');
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);
});
@@ -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/);
});
}
+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);
});