mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-03 16:19:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb3dbf32b |
@@ -8,6 +8,9 @@ on:
|
||||
# CLI is verified separately; its shared Bash upgrade engine ships in images.
|
||||
- "!cli/**"
|
||||
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
||||
- "cli/src/internal/maintenance/platformBundle.cjs"
|
||||
- "cli/src/internal/maintenance/upgradePrestart.cjs"
|
||||
- "cli/src/internal/maintenance/panelUpgrade.cjs"
|
||||
- "!.github/workflows/cli-package.yml"
|
||||
branches:
|
||||
- "master"
|
||||
@@ -203,6 +206,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 +273,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 +295,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 +361,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 +572,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"
|
||||
|
||||
+11
-28
@@ -41,11 +41,18 @@ class Application {
|
||||
}
|
||||
|
||||
private startMasterProcess() {
|
||||
const readyTimeout =
|
||||
process.arch === 'arm' &&
|
||||
Number(
|
||||
(process.config.variables as { arm_version?: string | number }).arm_version,
|
||||
) === 6
|
||||
? 120000
|
||||
: 30000;
|
||||
// Fork gRPC worker first and wait for it to be ready
|
||||
const grpcWorker = this.forkWorker('grpc');
|
||||
|
||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||
this.waitForWorkerReady(grpcWorker, 30000)
|
||||
this.waitForWorkerReady(grpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||
this.httpWorker = this.forkWorker('http');
|
||||
@@ -71,7 +78,7 @@ class Application {
|
||||
Logger.warn('Unable to notify HTTP worker of scheduler exit');
|
||||
}
|
||||
const newGrpcWorker = this.forkWorker('grpc');
|
||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||
this.waitForWorkerReady(newGrpcWorker, readyTimeout)
|
||||
.then(() => {
|
||||
Logger.info('gRPC worker restarted and ready');
|
||||
// Re-register cron jobs by notifying the HTTP worker
|
||||
@@ -148,32 +155,8 @@ class Application {
|
||||
}
|
||||
|
||||
private async setupMiddlewares(): Promise<express.Application> {
|
||||
// Only the HTTP worker needs an Express application and its middleware.
|
||||
const [
|
||||
{ default: createExpress },
|
||||
{ default: helmet },
|
||||
{ default: cors },
|
||||
{ default: compression },
|
||||
{ monitoringMiddleware },
|
||||
] = await Promise.all([
|
||||
import('express'),
|
||||
import('helmet'),
|
||||
import('cors'),
|
||||
import('compression'),
|
||||
import('./middlewares/monitoring'),
|
||||
]);
|
||||
const app = createExpress();
|
||||
app.use((req, res, next) => {
|
||||
if (req.query.t) delete req.query.t;
|
||||
next();
|
||||
});
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
app.use(cors(config.cors));
|
||||
app.use(compression());
|
||||
app.use(monitoringMiddleware);
|
||||
return app;
|
||||
const { createHttpApplication } = await import('./loaders/httpApplication');
|
||||
return createHttpApplication();
|
||||
}
|
||||
|
||||
private setupMasterShutdown() {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// A single, disposable backend process. Resolve startup dependencies, but never
|
||||
// run appLoader: its initData/initTask hooks install dependencies and start user work.
|
||||
import 'reflect-metadata';
|
||||
import dns from 'dns';
|
||||
import http from 'http';
|
||||
|
||||
async function probeUpgrade() {
|
||||
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
||||
throw new Error('Upgrade probe requires its isolated supervisor');
|
||||
dns.setDefaultResultOrder('ipv4first');
|
||||
const { default: config } = await import('../config');
|
||||
const { sequelize } = await import('../data');
|
||||
const { default: loadDatabase } = await import('../loaders/db');
|
||||
const { Container } = await import('typedi');
|
||||
const { GrpcServerService } = await import('../services/grpc');
|
||||
const { HttpServerService } = await import('../services/http');
|
||||
const { createHttpApplication } = await import('../loaders/httpApplication');
|
||||
const { default: loadServer } = await import('../loaders/server');
|
||||
const { default: loadExpress } = await import('../loaders/express');
|
||||
await import('../loaders/app'); // Resolve normal startup hooks without running them.
|
||||
const { default: inject } = await import('../loaders/depInjector');
|
||||
const grpc = Container.get(GrpcServerService);
|
||||
const server = Container.get(HttpServerService);
|
||||
try {
|
||||
await loadDatabase();
|
||||
// Exercise an actual on-disk transaction as well as schema initialization.
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', {
|
||||
transaction,
|
||||
});
|
||||
await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', {
|
||||
transaction,
|
||||
});
|
||||
const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', {
|
||||
transaction,
|
||||
});
|
||||
if ((rows[0] as { value: number })?.value !== 42)
|
||||
throw new Error('Upgrade database readback failed');
|
||||
});
|
||||
inject();
|
||||
config.grpcPort = await grpc.initialize();
|
||||
const { default: cronClient } = await import('../schedule/client');
|
||||
cronClient.readiness.configure(async () => {});
|
||||
if (!(await cronClient.readiness.recover()))
|
||||
throw new Error('Upgrade scheduler readiness failed');
|
||||
const app = await createHttpApplication();
|
||||
loadExpress({ app });
|
||||
const listener = await server.initialize(app, 0);
|
||||
await loadServer({ server: listener });
|
||||
const address = listener.address();
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('No probe HTTP port');
|
||||
const health = await new Promise<any>((resolve, reject) => {
|
||||
const request = http.get(
|
||||
`http://127.0.0.1:${address.port}/api/health`,
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('error', reject);
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const result = JSON.parse(body);
|
||||
if (
|
||||
response.statusCode !== 200 ||
|
||||
result.code !== 200 ||
|
||||
result.data?.status !== 'ok' ||
|
||||
!result.data.services?.grpc ||
|
||||
!result.data.services?.http
|
||||
)
|
||||
throw new Error('Upgrade HTTP/gRPC health check failed');
|
||||
resolve(result.data);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.setTimeout(10000, () =>
|
||||
request.destroy(new Error('Upgrade health request timed out')),
|
||||
);
|
||||
request.on('error', reject);
|
||||
});
|
||||
process.send!({
|
||||
type: 'upgrade-probe',
|
||||
ok: true,
|
||||
build: health.build,
|
||||
memory: process.memoryUsage(),
|
||||
pid: process.pid,
|
||||
});
|
||||
} finally {
|
||||
await server.shutdown();
|
||||
grpc.getServer().forceShutdown();
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
process.once('disconnect', () => process.exit(1));
|
||||
probeUpgrade()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
process.send?.({
|
||||
type: 'upgrade-probe',
|
||||
ok: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type express from 'express';
|
||||
import config from '../config';
|
||||
|
||||
export async function createHttpApplication(): Promise<express.Application> {
|
||||
// Only the HTTP worker needs an Express application and its middleware.
|
||||
const [
|
||||
{ default: createExpress },
|
||||
{ default: helmet },
|
||||
{ default: cors },
|
||||
{ default: compression },
|
||||
{ monitoringMiddleware },
|
||||
] = await Promise.all([
|
||||
import('express'),
|
||||
import('helmet'),
|
||||
import('cors'),
|
||||
import('compression'),
|
||||
import('../middlewares/monitoring'),
|
||||
]);
|
||||
const app = createExpress();
|
||||
app.use((req, res, next) => {
|
||||
if (req.query.t) delete req.query.t;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}),
|
||||
);
|
||||
app.use(cors(config.cors));
|
||||
app.use(compression());
|
||||
app.use(monitoringMiddleware);
|
||||
return app;
|
||||
}
|
||||
@@ -50,13 +50,13 @@ export class GrpcServerService {
|
||||
for (const host of hostsToTry) {
|
||||
try {
|
||||
const address = this.formatGrpcAddress(host, grpcPort);
|
||||
await bindAsync(address, credentials);
|
||||
const boundPort = await bindAsync(address, credentials);
|
||||
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
|
||||
metricsService.record('grpc_service_start', 1, {
|
||||
port: grpcPort.toString(),
|
||||
host
|
||||
});
|
||||
return grpcPort;
|
||||
return boundPort;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err);
|
||||
|
||||
Generated
+76
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -17,6 +17,21 @@ fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
|
||||
);
|
||||
// The operational CLI may live outside the panel and has no panel node_modules.
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
|
||||
outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'),
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
minify: true,
|
||||
legalComments: 'inline',
|
||||
});
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||
|
||||
@@ -150,6 +150,14 @@ export async function startPanel(
|
||||
GRPC_PORT: context.env.QlGrpcPort || '5500',
|
||||
},
|
||||
};
|
||||
// Archive selection and launcher ownership apply only to the upgrade worker.
|
||||
// Retaining them in PM2 would pin the next online update to an offline file.
|
||||
for (const name of [
|
||||
'QL_UPGRADE_FILE',
|
||||
'QL_UPGRADE_REPAIR_DEPS',
|
||||
'QL_UPGRADE_LAUNCHER_PID',
|
||||
])
|
||||
delete options.env[name as keyof typeof options.env];
|
||||
if (await executableAvailable('pm2', context.env)) {
|
||||
const flushed = await runProcess('pm2', ['flush'], options);
|
||||
if (flushed.code === 0) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const { spawn } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const artifacts = require('./upgradeArtifacts.cjs');
|
||||
const { nodeRuntimeArgs } = require('./platformBundle.cjs');
|
||||
|
||||
const active = new Set(['starting', 'running', 'restarting']);
|
||||
const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json');
|
||||
@@ -200,6 +201,63 @@ async function getStatus(tmp) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// A shell/CLI restart can consume a bundle prepared by the offline installer
|
||||
// or panel. Keep that existing job honest without creating jobs for legacy
|
||||
// reloads which have no panel readiness record.
|
||||
async function withPreparedJob(tmp, env, selected, operation) {
|
||||
const previous = await getStatus(tmp);
|
||||
if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。');
|
||||
if (previous.status !== 'ready') return operation();
|
||||
return artifacts.withUpgradeLock(
|
||||
path.join(tmp, 'panel-job-launch'),
|
||||
async () => {
|
||||
const current = await getStatus(tmp);
|
||||
if (active.has(current.status))
|
||||
throw new Error('已有更新或重载正在执行。');
|
||||
if (current.status !== 'ready') return operation();
|
||||
assertSelected(current, await stagedMetadata(selected));
|
||||
const previousBuild = await readRunningBuild(env).catch(() => undefined);
|
||||
const { log, ...record } = current;
|
||||
let state = {
|
||||
...record,
|
||||
id: randomUUID(),
|
||||
action: 'apply',
|
||||
status: 'running',
|
||||
phase: 'verifying',
|
||||
startedAt: Date.now(),
|
||||
pid: process.pid,
|
||||
};
|
||||
const report = async (patch) => {
|
||||
state = { ...state, ...patch, updatedAt: Date.now() };
|
||||
await save(tmp, state);
|
||||
};
|
||||
await fs.writeFile(
|
||||
logFile(tmp),
|
||||
'Manual application of prepared update\n',
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
await report({});
|
||||
try {
|
||||
const result = await operation({
|
||||
onStop: () => report({ status: 'restarting', phase: 'restarting' }),
|
||||
onStart: (recover) =>
|
||||
recover
|
||||
? undefined
|
||||
: verifyRunningVersion(env, state, previousBuild),
|
||||
});
|
||||
await report({ status: 'completed', phase: 'completed' });
|
||||
return result;
|
||||
} catch (error) {
|
||||
await report({ status: 'failed', error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
env,
|
||||
);
|
||||
}
|
||||
|
||||
async function startJob({ root, tmp, env = process.env }, action) {
|
||||
if (!['download', 'apply'].includes(action))
|
||||
throw new Error('Invalid upgrade action');
|
||||
@@ -220,12 +278,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 +450,7 @@ async function runJob({ root, tmp, env = process.env }, id) {
|
||||
);
|
||||
await artifacts.waitForHealth(
|
||||
env,
|
||||
60000,
|
||||
undefined,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
if (!recover)
|
||||
@@ -429,16 +489,30 @@ async function runJob({ root, tmp, env = process.env }, id) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, startJob, runJob };
|
||||
module.exports = {
|
||||
getStatus,
|
||||
saveState: save,
|
||||
startJob,
|
||||
runJob,
|
||||
withPanelLifecycle,
|
||||
readRunningBuild,
|
||||
verifyRunningVersion,
|
||||
stagedMetadata,
|
||||
withPreparedJob,
|
||||
};
|
||||
async function launchWorker(id) {
|
||||
// detached creates a session but keeps the original parent. PM2 kills the
|
||||
// backend's entire descendant tree, so an intermediate launcher must exit
|
||||
// before the worker is allowed to stop that backend.
|
||||
const child = spawn(process.execPath, [__filename, 'worker', id], {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...nodeRuntimeArgs(), __filename, 'worker', id],
|
||||
{
|
||||
env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) },
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
},
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
// One installed verifier handles release downloads and local offline archives.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const os = require('node:os');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const tar = require('tar');
|
||||
const {
|
||||
prestartBackend,
|
||||
dependencyFailure,
|
||||
repairDependencies,
|
||||
} = require('./upgradePrestart.cjs');
|
||||
const execute = promisify(execFile);
|
||||
// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep
|
||||
// upgrade workers interpreted without changing the installed Node or server.
|
||||
const nodeRuntimeArgs = (runtime = process) =>
|
||||
runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6
|
||||
? ['--jitless']
|
||||
: [];
|
||||
const sha256 = (bytes) =>
|
||||
crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const inside = (root, filename) => {
|
||||
const relative = path.relative(root, filename);
|
||||
return (
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
};
|
||||
const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number);
|
||||
function atLeast(actual, minimum) {
|
||||
const a = versionParts(actual),
|
||||
b = versionParts(minimum);
|
||||
return (
|
||||
b.every((n) => Number.isFinite(n)) &&
|
||||
a.every((n) => Number.isFinite(n)) &&
|
||||
(a[0] > b[0] ||
|
||||
(a[0] === b[0] &&
|
||||
(a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0)))))
|
||||
);
|
||||
}
|
||||
|
||||
async function runtimeTarget() {
|
||||
let libc, libcVersion;
|
||||
if (process.platform === 'linux') {
|
||||
const header = process.report.getReport().header;
|
||||
if (header.glibcVersionRuntime) {
|
||||
libc = 'glibc';
|
||||
libcVersion = header.glibcVersionRuntime;
|
||||
} else {
|
||||
let text = '';
|
||||
try {
|
||||
text = (await execute('ldd', ['--version'])).stdout;
|
||||
} catch (error) {
|
||||
text = `${error.stdout || ''}\n${error.stderr || ''}`;
|
||||
}
|
||||
const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i);
|
||||
if (!match)
|
||||
throw new Error(
|
||||
'Cannot identify Linux libc; refusing a native dependency bundle',
|
||||
);
|
||||
libc = 'musl';
|
||||
libcVersion = match[1];
|
||||
}
|
||||
}
|
||||
const armVersion =
|
||||
process.arch === 'arm'
|
||||
? Number(process.config.variables.arm_version)
|
||||
: undefined;
|
||||
if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion))
|
||||
throw new Error('Cannot identify ARM instruction set');
|
||||
const arch =
|
||||
process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch;
|
||||
const armFloatAbi =
|
||||
process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined;
|
||||
if (
|
||||
process.arch === 'arm' &&
|
||||
!['hard', 'soft', 'softfp'].includes(armFloatAbi)
|
||||
)
|
||||
throw new Error('Cannot identify ARM floating-point ABI');
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch,
|
||||
libc,
|
||||
libcVersion,
|
||||
endianness: os.endianness(),
|
||||
armFloatAbi,
|
||||
};
|
||||
}
|
||||
|
||||
function targetKey(target) {
|
||||
return [target.platform, target.arch, target.libc].filter(Boolean).join('-');
|
||||
}
|
||||
|
||||
function assertCompatible(manifest, target, node = process.versions.node) {
|
||||
if (
|
||||
manifest.format !== 2 ||
|
||||
!/^[a-f0-9]{40}$/.test(manifest.sourceCommit) ||
|
||||
!/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) ||
|
||||
!manifest.files ||
|
||||
typeof manifest.files !== 'object' ||
|
||||
Array.isArray(manifest.files) ||
|
||||
!Number.isSafeInteger(manifest.nodeApi) ||
|
||||
manifest.nodeApi < 6
|
||||
)
|
||||
throw new Error('Invalid platform bundle manifest');
|
||||
if (targetKey(manifest.target || {}) !== targetKey(target))
|
||||
throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`);
|
||||
if (
|
||||
!atLeast(node, manifest.nodeMinimum) ||
|
||||
Number(process.versions.napi) < manifest.nodeApi
|
||||
)
|
||||
throw new Error(
|
||||
`Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`,
|
||||
);
|
||||
if (
|
||||
manifest.target.endianness !== target.endianness ||
|
||||
manifest.target.armFloatAbi !== target.armFloatAbi
|
||||
)
|
||||
throw new Error(
|
||||
'Bundle native byte order or ARM floating-point ABI mismatch',
|
||||
);
|
||||
if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum))
|
||||
throw new Error(
|
||||
`Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function inventory(roots) {
|
||||
const files = {};
|
||||
async function visit(directory, prefix) {
|
||||
for (const name of (await fs.readdir(directory)).sort()) {
|
||||
const filename = path.join(directory, name),
|
||||
relative = `${prefix}/${name}`;
|
||||
const stat = await fs.lstat(filename);
|
||||
if (stat.isDirectory()) await visit(filename, relative);
|
||||
else if (stat.isFile())
|
||||
files[relative] = {
|
||||
type: 'file',
|
||||
mode: stat.mode & 0o777,
|
||||
sha256: sha256(await fs.readFile(filename)),
|
||||
};
|
||||
else if (stat.isSymbolicLink()) {
|
||||
const link = await fs.readlink(filename);
|
||||
const boundary = roots[prefix.split('/')[0]];
|
||||
if (
|
||||
path.isAbsolute(link) ||
|
||||
!inside(boundary, path.resolve(path.dirname(filename), link)) ||
|
||||
!inside(await fs.realpath(boundary), await fs.realpath(filename))
|
||||
)
|
||||
throw new Error(`Bundle link escapes its payload: ${relative}`);
|
||||
files[relative] = { type: 'link', link };
|
||||
} else throw new Error(`Unsupported bundle entry: ${relative}`);
|
||||
}
|
||||
}
|
||||
for (const [name, root] of Object.entries(roots)) await visit(root, name);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function verifyPayload(selected, manifest, target) {
|
||||
assertCompatible(manifest, target);
|
||||
for (const name of ['data', '.tmp', '.git', '.env', 'static']) {
|
||||
if (
|
||||
await fs.lstat(path.join(selected.source, name)).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return null;
|
||||
})
|
||||
)
|
||||
throw new Error(`Reserved installation path in bundle: ${name}`);
|
||||
}
|
||||
const actual = await inventory({
|
||||
source: selected.source,
|
||||
static: selected.static,
|
||||
});
|
||||
const names = Object.keys(actual).sort(),
|
||||
expected = Object.keys(manifest.files).sort();
|
||||
if (
|
||||
JSON.stringify(names) !== JSON.stringify(expected) ||
|
||||
names.some(
|
||||
(name) =>
|
||||
JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Platform bundle files or dependency checksums do not match',
|
||||
);
|
||||
if (
|
||||
sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !==
|
||||
manifest.lockfileSha256
|
||||
)
|
||||
throw new Error('Platform bundle lockfile mismatch');
|
||||
await fs.access(
|
||||
path.join(selected.source, 'node_modules/sqlite3/package.json'),
|
||||
);
|
||||
}
|
||||
|
||||
async function smokeDependencies(source, signal) {
|
||||
// This probe is installed verifier code, never a verifier supplied by an archive.
|
||||
const probe = `
|
||||
const path = require('node:path'),fs = require('node:fs');
|
||||
const root = fs.realpathSync(process.cwd());
|
||||
const r = require('node:module').createRequire(path.join(root,'package.json'));
|
||||
for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) {
|
||||
if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name);
|
||||
r(name);
|
||||
}
|
||||
const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:');
|
||||
db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)');
|
||||
db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});});
|
||||
`;
|
||||
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
|
||||
// WebAssembly, which --jitless disables; only archive workers need that flag.
|
||||
await execute(process.execPath, ['-e', probe], {
|
||||
cwd: source,
|
||||
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
||||
timeout: 30000,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArchive(archive, destination) {
|
||||
const entries = new Map(),
|
||||
links = new Set();
|
||||
let unpacked = 0,
|
||||
invalid;
|
||||
await tar.t({
|
||||
file: archive,
|
||||
strict: true,
|
||||
onReadEntry(entry) {
|
||||
try {
|
||||
const name = entry.path.replace(/\/$/, '');
|
||||
if (
|
||||
!name ||
|
||||
name.includes('\\') ||
|
||||
/[\x00-\x1f\x7f]/.test(name) ||
|
||||
name.split('/').some((p) => !p || p === '.' || p === '..') ||
|
||||
!(name === 'qinglong' || name.startsWith('qinglong/')) ||
|
||||
entries.has(name) ||
|
||||
!['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type)
|
||||
)
|
||||
throw new Error(`Unsafe bundle archive entry: ${name}`);
|
||||
unpacked += entry.size;
|
||||
if (unpacked > 2 * 1024 ** 3 || entries.size > 200000)
|
||||
throw new Error('Bundle archive exceeds extraction limits');
|
||||
entries.set(name, entry.type);
|
||||
if (entry.type === 'SymbolicLink') {
|
||||
if (
|
||||
path.posix.isAbsolute(entry.linkpath) ||
|
||||
entry.linkpath.includes('\\') ||
|
||||
!inside(
|
||||
'/qinglong',
|
||||
path.posix.resolve('/', path.posix.dirname(name), entry.linkpath),
|
||||
)
|
||||
)
|
||||
throw new Error('Unsafe bundle symlink');
|
||||
links.add(name);
|
||||
}
|
||||
} catch (error) {
|
||||
invalid ||= error;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (invalid) throw invalid;
|
||||
for (const name of entries.keys()) {
|
||||
let parent = path.posix.dirname(name);
|
||||
while (parent !== '.') {
|
||||
if (links.has(parent))
|
||||
throw new Error('Archive writes through a symlink');
|
||||
parent = path.posix.dirname(parent);
|
||||
}
|
||||
}
|
||||
await tar.x({
|
||||
file: archive,
|
||||
cwd: destination,
|
||||
strict: true,
|
||||
preservePaths: false,
|
||||
noChown: true,
|
||||
umask: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function stageBundle({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
run,
|
||||
signal,
|
||||
onProgress,
|
||||
verifyStatic,
|
||||
download,
|
||||
probe = smokeDependencies,
|
||||
prestart = prestartBackend,
|
||||
repair = repairDependencies,
|
||||
}) {
|
||||
let local = env.QL_UPGRADE_FILE;
|
||||
if (!local) {
|
||||
const current = JSON.parse(
|
||||
await fs
|
||||
.readFile(path.join(root, 'package.json'), 'utf8')
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return '{}';
|
||||
}),
|
||||
);
|
||||
if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases.
|
||||
}
|
||||
const target = await runtimeTarget();
|
||||
const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH)
|
||||
? 'develop'
|
||||
: 'master';
|
||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||
const report = onProgress || (async () => {});
|
||||
try {
|
||||
const archive = path.join(workspace, 'bundle.tar.gz');
|
||||
let expectedCommit;
|
||||
if (local) {
|
||||
if (!path.isAbsolute(local))
|
||||
throw new Error('Offline bundle path must be absolute');
|
||||
// Snapshot the local input so replacing it cannot race validation/extraction.
|
||||
await fs.copyFile(local, archive);
|
||||
} else {
|
||||
const indexUrl =
|
||||
env.QL_UPGRADE_INDEX_URL ||
|
||||
`https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`;
|
||||
const checkUrl = (url) => {
|
||||
const parsed = new URL(url);
|
||||
if (
|
||||
parsed.protocol !== 'https:' &&
|
||||
!(
|
||||
parsed.protocol === 'http:' &&
|
||||
['127.0.0.1', '[::1]'].includes(parsed.hostname)
|
||||
)
|
||||
)
|
||||
throw new Error('Upgrade URLs require HTTPS');
|
||||
};
|
||||
checkUrl(indexUrl);
|
||||
const indexFile = path.join(workspace, 'index.json');
|
||||
await run('curl', [
|
||||
'--fail',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--proto',
|
||||
new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--header',
|
||||
'Cache-Control: no-cache',
|
||||
'--connect-timeout',
|
||||
'10',
|
||||
'--max-time',
|
||||
'30',
|
||||
'--max-filesize',
|
||||
'1048576',
|
||||
'--output',
|
||||
indexFile,
|
||||
indexUrl,
|
||||
]);
|
||||
const index = JSON.parse(await fs.readFile(indexFile, 'utf8'));
|
||||
const entry = index.packages?.[targetKey(target)];
|
||||
if (
|
||||
index.format !== 2 ||
|
||||
!/^[a-f0-9]{40}$/.test(index.sourceCommit) ||
|
||||
!entry ||
|
||||
!Number.isSafeInteger(entry.size) ||
|
||||
entry.size <= 0 ||
|
||||
entry.size > 512 * 1024 ** 2 ||
|
||||
!/^[a-f0-9]{64}$/.test(entry.sha256)
|
||||
)
|
||||
throw new Error(`No verified update bundle for ${targetKey(target)}`);
|
||||
checkUrl(entry.url);
|
||||
expectedCommit = index.sourceCommit;
|
||||
await report({
|
||||
phase: 'downloading',
|
||||
artifact: 'qinglong-bundle',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: entry.size,
|
||||
percent: 0,
|
||||
});
|
||||
await download(
|
||||
run,
|
||||
archive,
|
||||
entry.url,
|
||||
'qinglong-bundle',
|
||||
report,
|
||||
true,
|
||||
entry,
|
||||
);
|
||||
}
|
||||
if ((await fs.stat(archive)).size > 512 * 1024 ** 2)
|
||||
throw new Error('Bundle is too large');
|
||||
await report({
|
||||
phase: 'extracting',
|
||||
artifact: 'qinglong-bundle',
|
||||
percent: null,
|
||||
});
|
||||
await extractArchive(archive, workspace);
|
||||
const incoming = path.join(workspace, 'qinglong');
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'),
|
||||
);
|
||||
if (expectedCommit && manifest.sourceCommit !== expectedCommit)
|
||||
throw new Error('Update index and bundle commit differ');
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||
};
|
||||
await fs.rename(path.join(incoming, 'source'), selected.source);
|
||||
await fs.rename(path.join(incoming, 'static'), selected.static);
|
||||
await report({ phase: 'verifying', percent: null });
|
||||
await verifyPayload(selected, manifest, target);
|
||||
const build = await verifyStatic(selected.source, selected.static, env);
|
||||
if (build.sourceCommit !== manifest.sourceCommit)
|
||||
throw new Error('Bundle and build commit differ');
|
||||
await report({ phase: 'dependencies', percent: null });
|
||||
let dependencyFiles;
|
||||
const testCandidate = async () => {
|
||||
await probe(selected.source, signal);
|
||||
await report({ phase: 'prestarting', percent: null });
|
||||
return prestart(selected, { target, signal });
|
||||
};
|
||||
try {
|
||||
await testCandidate();
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (!dependencyFailure(error)) throw error;
|
||||
if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1')
|
||||
throw new Error(
|
||||
`Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`,
|
||||
);
|
||||
await report({ phase: 'repairing', percent: null });
|
||||
await repair(selected.source, { target, signal, env });
|
||||
const repaired = await inventory({
|
||||
source: selected.source,
|
||||
static: selected.static,
|
||||
});
|
||||
dependencyFiles = Object.fromEntries(
|
||||
Object.entries(repaired).filter(([name]) =>
|
||||
name.startsWith('source/node_modules/'),
|
||||
),
|
||||
);
|
||||
// A repair may change only dependencies, never bless changed application code.
|
||||
await verifyPayload(
|
||||
selected,
|
||||
manifestWithDependencies(manifest, dependencyFiles),
|
||||
target,
|
||||
);
|
||||
await verifyStatic(selected.source, selected.static, env);
|
||||
await testCandidate(); // Exactly one repair; this failure propagates.
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'prestart.json'),
|
||||
JSON.stringify({
|
||||
format: 1,
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))),
|
||||
node: process.version,
|
||||
execPath: process.execPath,
|
||||
completedAt: Date.now(),
|
||||
...(dependencyFiles ? { dependencyFiles } : {}),
|
||||
}),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'bundle.json'),
|
||||
JSON.stringify(manifest),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({
|
||||
...selected,
|
||||
root: path.resolve(root),
|
||||
sourceCommit: manifest.sourceCommit,
|
||||
dependenciesChanged: true,
|
||||
bundle: true,
|
||||
}),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const pointer = path.join(workspace, 'pointer.json');
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ directory: path.basename(workspace) }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`);
|
||||
const previous = await fs
|
||||
.readFile(readyPointer, 'utf8')
|
||||
.catch(() => 'null');
|
||||
await fs.rename(pointer, readyPointer);
|
||||
try {
|
||||
const old = JSON.parse(previous)?.directory;
|
||||
if (
|
||||
/^upgrade-[A-Za-z0-9_-]+$/.test(old) &&
|
||||
old !== path.basename(workspace)
|
||||
)
|
||||
await fs.rm(path.join(tmp, old), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* Committed readiness must survive cleanup failure. */
|
||||
}
|
||||
return selected;
|
||||
} catch (error) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function manifestWithDependencies(manifest, dependencyFiles) {
|
||||
if (!dependencyFiles) return manifest;
|
||||
if (
|
||||
typeof dependencyFiles !== 'object' ||
|
||||
Array.isArray(dependencyFiles) ||
|
||||
!Object.keys(dependencyFiles).length ||
|
||||
Object.keys(dependencyFiles).some(
|
||||
(name) => !name.startsWith('source/node_modules/'),
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid repaired dependency inventory');
|
||||
return {
|
||||
...manifest,
|
||||
files: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(manifest.files).filter(
|
||||
([name]) => !name.startsWith('source/node_modules/'),
|
||||
),
|
||||
),
|
||||
...dependencyFiles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyStagedBundle(selected) {
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(selected.source), 'bundle.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const prestart = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(selected.source), 'prestart.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
if (
|
||||
prestart.format !== 1 ||
|
||||
prestart.sourceCommit !== manifest.sourceCommit ||
|
||||
prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) ||
|
||||
prestart.node !== process.version ||
|
||||
prestart.execPath !== process.execPath ||
|
||||
!Number.isSafeInteger(prestart.completedAt)
|
||||
)
|
||||
throw new Error(
|
||||
'Upgrade prestart receipt is missing or stale; prepare the update again',
|
||||
);
|
||||
await verifyPayload(
|
||||
selected,
|
||||
manifestWithDependencies(manifest, prestart.dependencyFiles),
|
||||
await runtimeTarget(),
|
||||
);
|
||||
await smokeDependencies(selected.source);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nodeRuntimeArgs,
|
||||
atLeast,
|
||||
runtimeTarget,
|
||||
targetKey,
|
||||
assertCompatible,
|
||||
inventory,
|
||||
verifyPayload,
|
||||
smokeDependencies,
|
||||
extractArchive,
|
||||
stageBundle,
|
||||
verifyStagedBundle,
|
||||
sha256,
|
||||
};
|
||||
@@ -107,7 +107,7 @@ async function selectedUpgrade(
|
||||
|
||||
export interface ReloadLifecycle {
|
||||
stop(context: LocalContext): Promise<void>;
|
||||
start(context: LocalContext): Promise<unknown>;
|
||||
start(context: LocalContext, recover?: boolean): Promise<unknown>;
|
||||
}
|
||||
|
||||
// All old entries stay on the same filesystem until service start succeeds.
|
||||
@@ -202,7 +202,7 @@ export async function replaceAndReload(
|
||||
if (item.existed) await fs.rename(item.backup, item.target);
|
||||
}
|
||||
try {
|
||||
await lifecycle.start(context);
|
||||
await lifecycle.start(context, true);
|
||||
} catch {
|
||||
throw new Error(
|
||||
translate(
|
||||
@@ -300,14 +300,31 @@ export async function reloadPanel(
|
||||
return await artifacts.withUpgradeLock(
|
||||
context.paths.dir_tmp!,
|
||||
() =>
|
||||
require('./panelUpgrade.cjs').withPreparedJob(
|
||||
context.paths.dir_tmp!,
|
||||
context.env,
|
||||
selected,
|
||||
(job?: {
|
||||
onStop(): Promise<void>;
|
||||
onStart(recover?: boolean): Promise<void> | undefined;
|
||||
}) =>
|
||||
replaceAndReload(context, replacements, {
|
||||
stop: stopPanel,
|
||||
start: async (ctx) => {
|
||||
stop: async (ctx) => {
|
||||
await job?.onStop();
|
||||
await stopPanel(ctx);
|
||||
},
|
||||
start: async (ctx, recover) => {
|
||||
const service = await startPanel(ctx);
|
||||
await artifacts.waitForHealth(ctx.env, 60000, operationSignal());
|
||||
await artifacts.waitForHealth(
|
||||
ctx.env,
|
||||
undefined,
|
||||
operationSignal(),
|
||||
);
|
||||
await job?.onStart(recover);
|
||||
return service;
|
||||
},
|
||||
}),
|
||||
),
|
||||
context.env,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -218,13 +218,19 @@ async function downloadWithProgress(
|
||||
'--location',
|
||||
...(quiet ? ['--silent', '--show-error'] : []),
|
||||
'--proto',
|
||||
'=https',
|
||||
/^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url)
|
||||
? '=http,https'
|
||||
: '=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'30',
|
||||
'--max-time',
|
||||
'600',
|
||||
String(
|
||||
artifact === 'qinglong-bundle' && expected?.size
|
||||
? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120))
|
||||
: 600,
|
||||
),
|
||||
'--speed-limit',
|
||||
'1024',
|
||||
'--speed-time',
|
||||
@@ -260,6 +266,26 @@ async function stageUpgrade({
|
||||
signal,
|
||||
onProgress,
|
||||
}) {
|
||||
const currentPackage = JSON.parse(
|
||||
await fs
|
||||
.readFile(path.join(root, 'package.json'), 'utf8')
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return '{}';
|
||||
}),
|
||||
);
|
||||
if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) {
|
||||
return require('./platformBundle.cjs').stageBundle({
|
||||
root,
|
||||
tmp,
|
||||
env,
|
||||
run,
|
||||
signal,
|
||||
onProgress,
|
||||
verifyStatic: verifyUpgrade,
|
||||
download: downloadWithProgress,
|
||||
});
|
||||
}
|
||||
const report = onProgress || (async () => {});
|
||||
const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-'));
|
||||
const branch = releaseBranch(env);
|
||||
@@ -469,7 +495,12 @@ async function stageUpgrade({
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedUpgrade(tmp, branch, env = process.env) {
|
||||
async function selectedUpgrade(
|
||||
tmp,
|
||||
branch,
|
||||
env = process.env,
|
||||
{ metadataOnly = false } = {},
|
||||
) {
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'),
|
||||
);
|
||||
@@ -493,9 +524,13 @@ async function selectedUpgrade(tmp, branch, env = process.env) {
|
||||
);
|
||||
if (ready.source !== selected.source || ready.static !== selected.static)
|
||||
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||
const manifest = await verifyUpgrade(selected.source, selected.static, env);
|
||||
const manifest = metadataOnly
|
||||
? await manifestAt(selected.static, env)
|
||||
: await verifyUpgrade(selected.source, selected.static, env);
|
||||
if (ready.sourceCommit !== manifest.sourceCommit)
|
||||
throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env));
|
||||
if (ready.bundle && !metadataOnly)
|
||||
await require('./platformBundle.cjs').verifyStagedBundle(selected);
|
||||
if (ready.dependenciesChanged)
|
||||
await fs.access(path.join(selected.source, 'node_modules'));
|
||||
else {
|
||||
@@ -571,7 +606,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) {
|
||||
} catch (error) {
|
||||
// Do not modify files under a still-running failed startup. Keep backups
|
||||
// for recovery if stopping the new process itself fails.
|
||||
if (attemptedStart) await lifecycle.stop();
|
||||
if (attemptedStart) await lifecycle.stop(true);
|
||||
for (const item of changed.reverse()) {
|
||||
await fs.rm(item.target, { recursive: true, force: true });
|
||||
if (item.existed) {
|
||||
@@ -640,7 +675,16 @@ async function reloadSystem({
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function waitForHealth(env, timeout = 60000, signal) {
|
||||
function startupHealthTimeout(runtime = process) {
|
||||
// ARMv6 starts the database, scheduler and HTTP workers sequentially. Give
|
||||
// slow devices time to become healthy before deciding to restore a backup.
|
||||
return runtime.arch === 'arm' &&
|
||||
Number(runtime.config.variables.arm_version) === 6
|
||||
? 300000
|
||||
: 60000;
|
||||
}
|
||||
|
||||
async function waitForHealth(env, timeout = startupHealthTimeout(), signal) {
|
||||
const port = Number(env.QlPort || 5700);
|
||||
let base = env.QlBaseUrl || '';
|
||||
if (base && !base.startsWith('/')) base = `/${base}`;
|
||||
@@ -786,9 +830,18 @@ esac
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
// Offline selection and worker ownership belong to this operation only.
|
||||
// Passing them to PM2 would pin future online updates to the old local file.
|
||||
const lifecycleEnv = { ...env };
|
||||
for (const name of [
|
||||
'QL_UPGRADE_FILE',
|
||||
'QL_UPGRADE_REPAIR_DEPS',
|
||||
'QL_UPGRADE_LAUNCHER_PID',
|
||||
])
|
||||
delete lifecycleEnv[name];
|
||||
const hook = (operation, args = [], signal) =>
|
||||
runProcess(
|
||||
env,
|
||||
lifecycleEnv,
|
||||
'bash',
|
||||
['--', snapshot, operation, ...args],
|
||||
false,
|
||||
@@ -821,16 +874,21 @@ async function main() {
|
||||
tmp,
|
||||
() =>
|
||||
withLifecycleHooks(root, tmp, env, async (hook) => {
|
||||
let job;
|
||||
const lifecycle = {
|
||||
signal: controller.signal,
|
||||
stop: () => hook('stop'),
|
||||
stop: async () => {
|
||||
await job?.onStop();
|
||||
await hook('stop');
|
||||
},
|
||||
start: async (recover) => {
|
||||
await hook('start', [], recover ? undefined : controller.signal);
|
||||
await waitForHealth(
|
||||
env,
|
||||
60000,
|
||||
undefined,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
await job?.onStart(recover);
|
||||
},
|
||||
};
|
||||
if (process.argv[2] === 'update') {
|
||||
@@ -850,6 +908,8 @@ async function main() {
|
||||
if (process.argv[4] !== 'true') return;
|
||||
} else if (process.argv[2] !== 'reload')
|
||||
throw new Error('Unknown upgrade command.');
|
||||
const apply = async (preparedJob) => {
|
||||
job = preparedJob;
|
||||
await reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
@@ -859,6 +919,27 @@ async function main() {
|
||||
config: env.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle,
|
||||
});
|
||||
};
|
||||
// Older installations can carry just this self-contained Bash verifier.
|
||||
// Load panel job dependencies only when there is a panel job to reconcile.
|
||||
const hasPanelJob = await fs
|
||||
.access(path.join(tmp, 'panel-upgrade.json'))
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return false;
|
||||
});
|
||||
if (hasPanelJob) {
|
||||
const selected = await selectedUpgrade(tmp, releaseBranch(env), env, {
|
||||
metadataOnly: true,
|
||||
});
|
||||
await require('./panelUpgrade.cjs').withPreparedJob(
|
||||
tmp,
|
||||
env,
|
||||
selected,
|
||||
apply,
|
||||
);
|
||||
} else await apply();
|
||||
console.log(upgradeMessage('更新及启动检查成功...', env));
|
||||
}),
|
||||
env,
|
||||
@@ -875,6 +956,7 @@ module.exports = {
|
||||
selectedUpgrade,
|
||||
replaceAndReload,
|
||||
reloadSystem,
|
||||
startupHealthTimeout,
|
||||
waitForHealth,
|
||||
runProcess,
|
||||
withLifecycleHooks,
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const os = require('node:os');
|
||||
|
||||
function probeLimits(target) {
|
||||
const small = target.arch === 'armv6';
|
||||
return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 };
|
||||
}
|
||||
|
||||
function dependencyFailure(error) {
|
||||
const text = `${error.message || ''}\n${error.stack || ''}\n${
|
||||
error.stderr || ''
|
||||
}`;
|
||||
if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text))
|
||||
return false;
|
||||
if (
|
||||
/ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) &&
|
||||
/node_modules[/\\]/.test(text)
|
||||
)
|
||||
return true;
|
||||
const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec(
|
||||
text,
|
||||
)?.[1];
|
||||
const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1];
|
||||
const inDependencies = (filename) =>
|
||||
/(?:^|[/\\])node_modules[/\\]/.test(filename || '');
|
||||
// Node reports a missing package entry as an absolute path and missing
|
||||
// package-internal imports as relative paths. Neither is an app source error.
|
||||
const dependencyMissing =
|
||||
missing &&
|
||||
((!missing.startsWith('.') &&
|
||||
!path.isAbsolute(missing) &&
|
||||
!missing.startsWith('node:')) ||
|
||||
inDependencies(missing) ||
|
||||
inDependencies(importer));
|
||||
// Missing backend files, bad config and database errors must never trigger npm.
|
||||
return (
|
||||
(/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) &&
|
||||
Boolean(dependencyMissing)) ||
|
||||
(/Could not locate the bindings file/.test(text) &&
|
||||
/node_modules[/\\]/.test(text))
|
||||
);
|
||||
}
|
||||
|
||||
async function prestartBackend(
|
||||
selected,
|
||||
{ target, signal, limits = probeLimits(target) },
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const workspace = await fs.mkdtemp(
|
||||
path.join(path.dirname(selected.source), 'prestart-'),
|
||||
);
|
||||
try {
|
||||
const data = path.join(workspace, 'data');
|
||||
for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload'])
|
||||
await fs.mkdir(path.join(data, name), { recursive: true });
|
||||
// Copy only backend code/metadata, never all frontend assets or node_modules.
|
||||
await fs.mkdir(path.join(workspace, 'static'));
|
||||
await fs.cp(
|
||||
path.join(selected.static, 'build'),
|
||||
path.join(workspace, 'static/build'),
|
||||
{ recursive: true },
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(selected.static, 'build-info.json'),
|
||||
path.join(workspace, 'static/build-info.json'),
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(selected.source, 'version.yaml'),
|
||||
path.join(workspace, 'version.yaml'),
|
||||
);
|
||||
await fs.symlink(
|
||||
path.join(selected.source, 'node_modules'),
|
||||
path.join(workspace, 'node_modules'),
|
||||
'dir',
|
||||
);
|
||||
await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 });
|
||||
const entry = path.join(
|
||||
workspace,
|
||||
'static/build/bootstrap/upgradeProbe.js',
|
||||
);
|
||||
await fs.access(entry);
|
||||
const expected = JSON.parse(
|
||||
await fs.readFile(path.join(selected.static, 'build-info.json')),
|
||||
);
|
||||
const backendSha256 = crypto
|
||||
.createHash('sha256')
|
||||
.update(await fs.readFile(path.join(selected.static, 'build/app.js')))
|
||||
.digest('hex');
|
||||
const version = (
|
||||
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
|
||||
).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
|
||||
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
|
||||
// package-manager settings from escaping into the disposable backend.
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
LANG: process.env.LANG || 'C.UTF-8',
|
||||
HOME: workspace,
|
||||
TMPDIR: workspace,
|
||||
NODE_ENV: 'production',
|
||||
QL_DIR: workspace,
|
||||
QL_DATA_DIR: data,
|
||||
QL_UPGRADE_PROBE: '1',
|
||||
BACK_PORT: '0',
|
||||
GRPC_PORT: '0',
|
||||
BIND_HOST: '127.0.0.1',
|
||||
BIND_HOST_GRPC: '127.0.0.1',
|
||||
QlBaseUrl: '/',
|
||||
NODE_PATH: '',
|
||||
NODE_OPTIONS: '',
|
||||
UV_THREADPOOL_SIZE: '1',
|
||||
};
|
||||
return await new Promise((resolve, reject) => {
|
||||
let report,
|
||||
failure,
|
||||
output = '',
|
||||
finished = false,
|
||||
killTimer;
|
||||
// No PM2/cluster workers. OpenSSL key generation is sequential in this
|
||||
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[`--max-old-space-size=${limits.heapMb}`, entry],
|
||||
{
|
||||
cwd: workspace,
|
||||
env,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (child.pid) os.setPriority(child.pid, 10);
|
||||
} catch {}
|
||||
const kill = (signalName) => {
|
||||
try {
|
||||
if (process.platform !== 'win32' && child.pid)
|
||||
process.kill(-child.pid, signalName);
|
||||
else child.kill(signalName);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') failure ||= error;
|
||||
}
|
||||
};
|
||||
const stop = (error) => {
|
||||
failure ||= error;
|
||||
kill('SIGTERM');
|
||||
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
|
||||
};
|
||||
const abort = () =>
|
||||
stop(signal.reason || new Error('Upgrade prestart aborted'));
|
||||
const timer = setTimeout(
|
||||
() => stop(new Error('Upgrade prestart timed out')),
|
||||
limits.timeoutMs,
|
||||
);
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output = (output + chunk).slice(-32768);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
output = (output + chunk).slice(-32768);
|
||||
});
|
||||
child.on('message', (message) => {
|
||||
if (message?.type === 'upgrade-probe') report = message;
|
||||
});
|
||||
const finish = (error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(killTimer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (error) reject(error);
|
||||
else resolve(report);
|
||||
};
|
||||
child.on('error', (error) => finish(error));
|
||||
// Descendants can retain our output pipes after their parent exits.
|
||||
// Reap the group on exit rather than waiting for those pipes to close.
|
||||
child.once('exit', () => kill('SIGKILL'));
|
||||
child.on('close', (code, exitSignal) => {
|
||||
// Reap descendants even when the backend exits unexpectedly.
|
||||
kill('SIGKILL');
|
||||
if (failure) return finish(failure);
|
||||
if (code !== 0 || !report?.ok)
|
||||
return finish(
|
||||
Object.assign(
|
||||
new Error(
|
||||
report?.message ||
|
||||
`Upgrade prestart exited: ${exitSignal || code}\n${output}`,
|
||||
),
|
||||
{
|
||||
code: report?.code,
|
||||
stack: report?.stack || output,
|
||||
signal: exitSignal,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (
|
||||
report.build?.sourceCommit !== expected.sourceCommit ||
|
||||
report.build?.backendSha256 !== backendSha256 ||
|
||||
report.build?.version !== version
|
||||
)
|
||||
return finish(new Error('Upgrade prestart build identity mismatch'));
|
||||
finish();
|
||||
});
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function repairDependencies(source, { target, signal, env }) {
|
||||
const workspace = await fs.mkdtemp(
|
||||
path.join(path.dirname(source), 'repair-'),
|
||||
);
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
await fs.readFile(path.join(source, 'package.json')),
|
||||
);
|
||||
if (pkg.packageManager !== 'pnpm@8.3.1')
|
||||
throw new Error('Unsupported frozen-lockfile package manager');
|
||||
// Install apart from the verified application: lifecycle scripts cannot
|
||||
// rewrite source/static files or use the old installation's node_modules.
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'package.json'),
|
||||
JSON.stringify({ ...pkg, scripts: {} }),
|
||||
);
|
||||
await fs.copyFile(
|
||||
path.join(source, 'pnpm-lock.yaml'),
|
||||
path.join(workspace, 'pnpm-lock.yaml'),
|
||||
);
|
||||
const small = target.arch === 'armv6';
|
||||
const childEnv = {
|
||||
PATH: env.PATH || process.env.PATH,
|
||||
HOME: workspace,
|
||||
TMPDIR: workspace,
|
||||
NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${
|
||||
small ? 256 : 384
|
||||
}`,
|
||||
NODE_PATH: '',
|
||||
CI: 'true',
|
||||
npm_config_jobs: '1',
|
||||
MAKEFLAGS: '-j1',
|
||||
...(small
|
||||
? {
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_force_process_config: 'true',
|
||||
CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||
CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// Preserve network routing without inheriting Node/preload/compiler settings.
|
||||
for (const name of [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'ALL_PROXY',
|
||||
'NO_PROXY',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'all_proxy',
|
||||
'no_proxy',
|
||||
])
|
||||
if (typeof env[name] === 'string') childEnv[name] = env[name];
|
||||
// Reuse matching installed headers on small devices; never inherit a
|
||||
// caller's nodedir or accidentally compile against a different Node ABI.
|
||||
const prefix = path.resolve(path.dirname(process.execPath), '..');
|
||||
const header = await fs
|
||||
.readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8')
|
||||
.catch(() => '');
|
||||
const headerVersion = ['MAJOR', 'MINOR', 'PATCH']
|
||||
.map(
|
||||
(part) =>
|
||||
header.match(
|
||||
new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`),
|
||||
)?.[1],
|
||||
)
|
||||
.join('.');
|
||||
if (headerVersion === process.versions.node) {
|
||||
childEnv.npm_config_nodedir = prefix;
|
||||
childEnv.npm_config_force_process_config = 'true';
|
||||
}
|
||||
const run = (args) =>
|
||||
runRepair('pnpm', args, {
|
||||
cwd: workspace,
|
||||
env: childEnv,
|
||||
signal,
|
||||
timeoutMs: small ? 1800000 : 600000,
|
||||
});
|
||||
const version = await run(['--version']);
|
||||
if (version.trim() !== '8.3.1')
|
||||
throw new Error('Dependency repair requires existing pnpm 8.3.1');
|
||||
await run([
|
||||
'install',
|
||||
'--prod',
|
||||
'--frozen-lockfile',
|
||||
'--ignore-scripts',
|
||||
'--child-concurrency=1',
|
||||
'--network-concurrency=2',
|
||||
]);
|
||||
await run(['rebuild', 'sqlite3', '@whyour/sqlite3']);
|
||||
if (
|
||||
!(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals(
|
||||
await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')),
|
||||
)
|
||||
)
|
||||
throw new Error('Dependency repair changed the frozen lockfile');
|
||||
await fs.rm(path.join(source, 'node_modules'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.rename(
|
||||
path.join(workspace, 'node_modules'),
|
||||
path.join(source, 'node_modules'),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Bound output, wall time and the entire npm/node-gyp process group.
|
||||
async function runRepair(program, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
options.signal?.throwIfAborted();
|
||||
let output = '',
|
||||
stdout = '',
|
||||
failure,
|
||||
killTimer;
|
||||
const child = spawn(program, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const kill = (sig) => {
|
||||
try {
|
||||
if (child.pid && process.platform !== 'win32')
|
||||
process.kill(-child.pid, sig);
|
||||
else child.kill(sig);
|
||||
} catch {}
|
||||
};
|
||||
const stop = (error) => {
|
||||
failure ||= error;
|
||||
kill('SIGTERM');
|
||||
killTimer ||= setTimeout(() => kill('SIGKILL'), 1500);
|
||||
};
|
||||
const abort = () =>
|
||||
stop(options.signal.reason || new Error('Dependency repair aborted'));
|
||||
const timer = setTimeout(
|
||||
() => stop(new Error('Dependency repair timed out')),
|
||||
options.timeoutMs,
|
||||
);
|
||||
const capture = (chunk) => {
|
||||
output = (output + chunk).slice(-32768);
|
||||
};
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = (stdout + chunk).slice(-32768);
|
||||
capture(chunk);
|
||||
});
|
||||
child.stderr.on('data', capture);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(killTimer);
|
||||
options.signal?.removeEventListener('abort', abort);
|
||||
kill('SIGKILL');
|
||||
};
|
||||
child.on('error', (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
child.once('exit', () => kill('SIGKILL'));
|
||||
child.on('close', (code, signal) => {
|
||||
cleanup();
|
||||
if (failure || code !== 0)
|
||||
reject(
|
||||
failure ||
|
||||
new Error(
|
||||
`Dependency repair failed (${signal || code}): ${output}`,
|
||||
),
|
||||
);
|
||||
else resolve(stdout);
|
||||
});
|
||||
options.signal?.addEventListener('abort', abort, { once: true });
|
||||
if (options.signal?.aborted) abort();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
prestartBackend,
|
||||
dependencyFailure,
|
||||
repairDependencies,
|
||||
probeLimits,
|
||||
};
|
||||
@@ -20,6 +20,40 @@ const {
|
||||
sourceCommit,
|
||||
} = require('../../../test/helpers/upgrade-fixture.cjs');
|
||||
|
||||
test('CLI service startup drops operation-only upgrade variables and preserves user configuration', async (t) => {
|
||||
const ctx = await fixture(t);
|
||||
ctx.env.CALLS = path.join(ctx.root, 'runtime.jsonl');
|
||||
ctx.env.QL_UPGRADE_FILE = '/old/offline.tar.gz';
|
||||
ctx.env.QL_UPGRADE_REPAIR_DEPS = '1';
|
||||
ctx.env.QL_UPGRADE_LAUNCHER_PID = '12345';
|
||||
ctx.env.QA_USER_SETTING = 'preserved';
|
||||
ctx.env.HTTPS_PROXY = 'http://127.0.0.1:7890';
|
||||
await stub(
|
||||
ctx,
|
||||
'pm2',
|
||||
`require('node:fs').appendFileSync(process.env.CALLS,JSON.stringify({
|
||||
file:process.env.QL_UPGRADE_FILE??null,repair:process.env.QL_UPGRADE_REPAIR_DEPS??null,
|
||||
launcher:process.env.QL_UPGRADE_LAUNCHER_PID??null,user:process.env.QA_USER_SETTING,
|
||||
proxy:process.env.HTTPS_PROXY,
|
||||
})+'\\n');`,
|
||||
);
|
||||
await startPanel(ctx);
|
||||
for (const runtime of (await fs.readFile(ctx.env.CALLS, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse))
|
||||
assert.deepEqual(runtime, {
|
||||
file: null,
|
||||
repair: null,
|
||||
launcher: null,
|
||||
user: 'preserved',
|
||||
proxy: 'http://127.0.0.1:7890',
|
||||
});
|
||||
assert.equal(ctx.env.QL_UPGRADE_FILE, '/old/offline.tar.gz');
|
||||
assert.equal(ctx.env.QL_UPGRADE_REPAIR_DEPS, '1');
|
||||
assert.equal(ctx.env.QL_UPGRADE_LAUNCHER_PID, '12345');
|
||||
});
|
||||
|
||||
async function fixture(t) {
|
||||
t.mock.method(
|
||||
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filename = path.join(directory, 'platformBundle.cjs');
|
||||
fs.copyFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../dist/internal/maintenance/platformBundle.cjs',
|
||||
),
|
||||
filename,
|
||||
);
|
||||
const code = `
|
||||
const Module=require('node:module'),load=Module._load;
|
||||
Module._load=function(name,...args){
|
||||
if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name);
|
||||
return load.call(this,name,...args);
|
||||
};
|
||||
const bundle=require(process.argv[1]);
|
||||
if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier');
|
||||
bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1});
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', code, filename], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NODE_PATH: '' },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`^${process.platform}-`));
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
manualUpgradeFixture,
|
||||
} = require('../../../test/helpers/manual-upgrade-fixture.cjs');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
||||
const operator = require('../../dist/internal/maintenance/operator');
|
||||
const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs');
|
||||
|
||||
for (const wrongVersion of [false, true]) {
|
||||
test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t, wrongVersion);
|
||||
const context = createContext({ root: f.root }, f.env);
|
||||
const timeouts = [];
|
||||
t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => {
|
||||
timeouts.push(timeout);
|
||||
});
|
||||
t.mock.method(operator, 'stopPanel', async () => {
|
||||
assert.equal((await f.state()).status, 'restarting');
|
||||
});
|
||||
t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' }));
|
||||
const operation = reloadPanel(context, 'system');
|
||||
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
|
||||
else await operation;
|
||||
assert.ok(timeouts.length > 0);
|
||||
assert.ok(
|
||||
timeouts.every((timeout) => timeout === undefined),
|
||||
'use the shared ARMv6/default startup deadline',
|
||||
);
|
||||
assert.equal(
|
||||
(await f.state()).status,
|
||||
wrongVersion ? 'failed' : 'completed',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
wrongVersion ? 'old backend' : 'new backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
if (wrongVersion)
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# ARMv7 is a source of application files only. None of its system libraries or
|
||||
# Node binaries are executed or copied into the ARMv6 runtime.
|
||||
ARG SOURCE_IMAGE
|
||||
FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source
|
||||
|
||||
# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime
|
||||
COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
debootstrap gpg gpgv ca-certificates curl qemu-user-static && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest
|
||||
# access checks. Pin the upstream static tracer instead of misresolving paths.
|
||||
RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \
|
||||
echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \
|
||||
chmod 755 /usr/local/bin/proot && proot --version
|
||||
RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \
|
||||
echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \
|
||||
gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \
|
||||
debootstrap --foreign --arch=armhf --variant=minbase \
|
||||
--keyring=/usr/share/keyrings/raspbian.gpg \
|
||||
bullseye /armv6 https://archive.raspbian.org/raspbian
|
||||
COPY docker/armv6-run /usr/local/bin/armv6-run
|
||||
RUN chmod 755 /usr/local/bin/armv6-run && \
|
||||
DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \
|
||||
cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \
|
||||
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \
|
||||
mkdir -p /armv6/etc/ssl/certs && \
|
||||
cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \
|
||||
printf '127.0.0.1 localhost\n::1 localhost\n' > /armv6/etc/hosts && \
|
||||
printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \
|
||||
chmod 755 /armv6/usr/sbin/policy-rc.d && \
|
||||
armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*'
|
||||
# This Node is a pinned CI/test prerequisite, never part of the upgrade archive.
|
||||
# Build against the minimum supported Node, including its bundled C++ headers.
|
||||
RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \
|
||||
echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \
|
||||
tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz
|
||||
COPY --from=source /ql/ /armv6/ql/
|
||||
# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries.
|
||||
COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/
|
||||
RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \
|
||||
armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -e '\''require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})'\''; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E "Tag_CPU_arch: v6($|K)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\'''
|
||||
LABEL qinglong.qa.runtime="armv6-glibc-arm1176"
|
||||
ENTRYPOINT ["/usr/local/bin/armv6-run"]
|
||||
@@ -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/ /
|
||||
Executable
+7
@@ -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,65 @@
|
||||
# 平台完整升级包
|
||||
|
||||
CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。
|
||||
|
||||
## 在线升级
|
||||
|
||||
`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。
|
||||
|
||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||
|
||||
渠道索引为 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
|
||||
```
|
||||
|
||||
仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如:
|
||||
|
||||
```sh
|
||||
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps
|
||||
```
|
||||
|
||||
修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。
|
||||
|
||||
修复继承调用环境中的标准 HTTP/HTTPS/ALL/NO_PROXY(含小写形式),其余 Node 预加载和编译参数仍隔离。修复安装或 SQLite 重建失败时,清理私有目录,保留当前服务、当前依赖及上一次就绪包;不会标记本次更新就绪。预启动或修复主进程退出时立即终止其进程组,防止持有输出管道的子进程继续写入已清理目录。
|
||||
|
||||
通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。
|
||||
|
||||
重启中断后的停止新服务、恢复文件和启动旧服务不再使用已经取消的信号。若新服务无法停止,则保留备份,避免在仍运行的进程下覆盖文件;若文件已恢复但旧服务也无法启动,任务仍报告失败。Bash 和本地 CLI 启动后端时都移除离线包选择、修复许可和升级启动器 PID,避免后续在线更新误用旧离线包;用户配置及代理保持不变。回滚恢复应用文件和依赖,不把用户数据库恢复到历史快照;数据库迁移仍需保持向后兼容。
|
||||
|
||||
安装到空目录:
|
||||
|
||||
```sh
|
||||
node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install
|
||||
QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh
|
||||
```
|
||||
|
||||
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
||||
|
||||
ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。
|
||||
|
||||
## 平台兼容
|
||||
|
||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||
- ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。
|
||||
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||
|
||||
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
||||
+4
-2
@@ -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
|
||||
}
|
||||
|
||||
Generated
+3
-5
@@ -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==}
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env node
|
||||
// Also published as a standalone, bundled installer for devices with Node only.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { promisify } = require('node:util');
|
||||
const execute = promisify(require('node:child_process').execFile);
|
||||
const { parseArgs } = require('node:util');
|
||||
const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs');
|
||||
const {
|
||||
nodeRuntimeArgs,
|
||||
} = require('../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
|
||||
async function runInstallerMain(argv = process.argv.slice(2)) {
|
||||
const flags = nodeRuntimeArgs();
|
||||
if (!flags.length || process.execArgv.includes('--jitless'))
|
||||
return installBundle(argv);
|
||||
const child = require('node:child_process').spawn(
|
||||
process.execPath,
|
||||
[...flags, __filename, ...argv],
|
||||
{ stdio: 'inherit', env: process.env },
|
||||
);
|
||||
const interrupt = () => child.kill('SIGINT');
|
||||
const terminate = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', interrupt);
|
||||
process.once('SIGTERM', terminate);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Offline installer exited: ${signal || code}`));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
process.removeListener('SIGINT', interrupt);
|
||||
process.removeListener('SIGTERM', terminate);
|
||||
}
|
||||
}
|
||||
|
||||
async function installBundle(argv = process.argv.slice(2), env = process.env) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
root: { type: 'string' },
|
||||
file: { type: 'string' },
|
||||
'download-only': { type: 'boolean' },
|
||||
install: { type: 'boolean' },
|
||||
'repair-deps': { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
if (!values.file || !(values.root || env.QL_DIR))
|
||||
throw new Error(
|
||||
'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install] [--repair-deps]',
|
||||
);
|
||||
if (values.install && values['download-only'])
|
||||
throw new Error('Choose --install or --download-only');
|
||||
const root = path.resolve(values.root || env.QL_DIR);
|
||||
const existing = await fs.readdir(root).catch((error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return [];
|
||||
});
|
||||
if (values.install && existing.length)
|
||||
throw new Error('--install requires an empty installation directory');
|
||||
if (!values.install) await fs.access(path.join(root, 'shell/share.sh'));
|
||||
let environment = {
|
||||
...env,
|
||||
QL_DIR: root,
|
||||
QL_UPGRADE_FILE: path.resolve(values.file),
|
||||
};
|
||||
if (!values.install) {
|
||||
const captured = await execute(
|
||||
'bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
'. "$1/shell/share.sh"\n. "$1/shell/env.sh"\nimport_config\nexport QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}"\nenv -0',
|
||||
'ql-offline',
|
||||
root,
|
||||
],
|
||||
{ env: environment, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const loaded = Object.fromEntries(
|
||||
captured.stdout.split('\0').flatMap((line) => {
|
||||
const match = /^([A-Za-z_][A-Za-z_0-9]*)=([\s\S]*)$/.exec(line);
|
||||
return match ? [[match[1], match[2]]] : [];
|
||||
}),
|
||||
);
|
||||
environment = {
|
||||
...environment,
|
||||
...loaded,
|
||||
QL_DIR: root,
|
||||
QL_UPGRADE_FILE: path.resolve(values.file),
|
||||
};
|
||||
}
|
||||
environment.QL_UPGRADE_REPAIR_DEPS = values['repair-deps'] ? '1' : '0';
|
||||
const freshWorkspace = values.install
|
||||
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-'))
|
||||
: undefined;
|
||||
const tmp = freshWorkspace || path.join(root, '.tmp');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(new Error('离线升级已中断'));
|
||||
process.once('SIGINT', abort);
|
||||
process.once('SIGTERM', abort);
|
||||
try {
|
||||
const operation = () =>
|
||||
artifacts.withUpgradeLock(
|
||||
tmp,
|
||||
async () => {
|
||||
let state;
|
||||
const report = async (patch) => {
|
||||
if (patch.phase) console.log(patch.phase);
|
||||
if (state) {
|
||||
state = {
|
||||
...state,
|
||||
...patch,
|
||||
pid: process.pid,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await jobs.saveState(tmp, state);
|
||||
}
|
||||
};
|
||||
if (!values.install) {
|
||||
const previous = await jobs.getStatus(tmp);
|
||||
if (['starting', 'running', 'restarting'].includes(previous.status))
|
||||
throw new Error('已有更新或重载正在执行。');
|
||||
state = {
|
||||
id: randomUUID(),
|
||||
action: values['download-only'] ? 'download' : 'apply',
|
||||
status: 'running',
|
||||
phase: 'verifying',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
await report({});
|
||||
await fs.writeFile(
|
||||
path.join(tmp, 'panel-upgrade.log'),
|
||||
'Offline bundle verification\n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const selected = await artifacts.stageUpgrade({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
run: (program, args, capture) =>
|
||||
artifacts.runProcess(
|
||||
environment,
|
||||
program,
|
||||
args,
|
||||
capture,
|
||||
controller.signal,
|
||||
),
|
||||
signal: controller.signal,
|
||||
onProgress: report,
|
||||
install: async () => {
|
||||
throw new Error(
|
||||
'Offline bundles never install npm dependencies',
|
||||
);
|
||||
},
|
||||
});
|
||||
if (state)
|
||||
await report({
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
...(await jobs.stagedMetadata(selected)),
|
||||
});
|
||||
if (values['download-only']) {
|
||||
console.log('更新已就绪;执行 ql reload system 应用更新。');
|
||||
return selected;
|
||||
}
|
||||
if (values.install) {
|
||||
// Build beside the destination, then atomically replace only an empty root.
|
||||
await fs.mkdir(path.dirname(root), { recursive: true });
|
||||
const publication = await fs.mkdtemp(
|
||||
path.join(path.dirname(root), '.qinglong-install-'),
|
||||
);
|
||||
try {
|
||||
await fs.chmod(publication, 0o755);
|
||||
await fs.cp(selected.source, publication, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await fs.cp(selected.static, path.join(publication, 'static'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.copyFile(
|
||||
path.join(publication, '.env.example'),
|
||||
path.join(publication, '.env'),
|
||||
require('node:fs').constants.COPYFILE_EXCL,
|
||||
);
|
||||
await fs.chmod(path.join(publication, '.env'), 0o600);
|
||||
controller.signal.throwIfAborted();
|
||||
await fs.rename(publication, root);
|
||||
} finally {
|
||||
await fs.rm(publication, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(path.dirname(selected.source), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.rm(
|
||||
path.join(
|
||||
tmp,
|
||||
`upgrade-ready-${artifacts.releaseBranch(environment)}.json`,
|
||||
),
|
||||
{ force: true },
|
||||
);
|
||||
console.log(
|
||||
`离线安装完成,保留现有 Node ${
|
||||
process.version
|
||||
}。启动服务:QL_DIR=${JSON.stringify(
|
||||
root,
|
||||
)} QL_DATA_DIR=${JSON.stringify(
|
||||
path.join(root, 'data'),
|
||||
)} bash ${JSON.stringify(
|
||||
path.join(root, 'shell/start-simplify.sh'),
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await jobs.withPanelLifecycle(
|
||||
root,
|
||||
tmp,
|
||||
environment,
|
||||
async (hook, prepareSelection) => {
|
||||
const expected = await jobs.stagedMetadata(selected);
|
||||
const previous = await jobs
|
||||
.readRunningBuild(environment)
|
||||
.catch(() => undefined);
|
||||
const selection = await prepareSelection?.(selected);
|
||||
try {
|
||||
return await artifacts.reloadSystem({
|
||||
root,
|
||||
tmp,
|
||||
env: environment,
|
||||
data: environment.dir_data || path.join(root, 'data'),
|
||||
staticRoot: path.join(root, 'static'),
|
||||
config:
|
||||
environment.dir_config || path.join(root, 'data/config'),
|
||||
lifecycle: {
|
||||
signal: controller.signal,
|
||||
stop: async (recover) => {
|
||||
await report({
|
||||
status: 'restarting',
|
||||
phase: 'restarting',
|
||||
});
|
||||
await hook(
|
||||
'stop',
|
||||
[],
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
},
|
||||
start: async (recover) => {
|
||||
await hook(
|
||||
'start',
|
||||
[],
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
await artifacts.waitForHealth(
|
||||
environment,
|
||||
undefined,
|
||||
recover ? undefined : controller.signal,
|
||||
);
|
||||
if (!recover)
|
||||
await jobs.verifyRunningVersion(
|
||||
environment,
|
||||
expected,
|
||||
previous,
|
||||
);
|
||||
},
|
||||
},
|
||||
additionalReplacements: selection
|
||||
? [{ source: selection.source, target: selection.target }]
|
||||
: [],
|
||||
});
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
await report({ status: 'completed', phase: 'completed' });
|
||||
console.log('升级成功,已保留现有 Node 并运行更新后的版本。');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (state) await report({ status: 'failed', error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environment,
|
||||
);
|
||||
return values.install
|
||||
? await operation()
|
||||
: await artifacts.withUpgradeLock(
|
||||
path.join(tmp, 'panel-job-launch'),
|
||||
operation,
|
||||
environment,
|
||||
);
|
||||
} finally {
|
||||
process.removeListener('SIGINT', abort);
|
||||
process.removeListener('SIGTERM', abort);
|
||||
if (freshWorkspace)
|
||||
await fs.rm(freshWorkspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { installBundle, runInstallerMain };
|
||||
if (require.main === module)
|
||||
runInstallerMain().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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...')"
|
||||
|
||||
@@ -2,7 +2,10 @@ import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||
import { Modal, Tooltip } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade';
|
||||
import useUpgrade, {
|
||||
activeStatuses,
|
||||
upgradeStatusLabel,
|
||||
} from '@/hooks/useUpgrade';
|
||||
import CheckUpdate from '@/pages/setting/checkUpdate';
|
||||
|
||||
export default function UpgradeNotice({
|
||||
@@ -21,16 +24,15 @@ export default function UpgradeNotice({
|
||||
if (!showNotice && !open) return null;
|
||||
const text = !connected
|
||||
? intl.get('连接已断开,正在重新连接')
|
||||
: job.status === 'failed'
|
||||
? intl.get('更新失败')
|
||||
: intl.get(phaseLabels[job.phase || ''] || '检查更新');
|
||||
: intl.get(upgradeStatusLabel(job));
|
||||
const label =
|
||||
activeStatuses.includes(job.status) &&
|
||||
job.phase === 'downloading' && job.percent != null
|
||||
? `${text} ${job.percent}%`
|
||||
: text;
|
||||
return (
|
||||
<>
|
||||
{showNotice && (
|
||||
{showNotice && !open && (
|
||||
<Tooltip title={label} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,11 +25,26 @@ export const phaseLabels: Record<string, string> = {
|
||||
extracting: '正在解压更新包',
|
||||
verifying: '正在校验更新包',
|
||||
dependencies: '正在准备依赖',
|
||||
prestarting: '正在预启动验证',
|
||||
repairing: '正在修复运行依赖',
|
||||
ready: '更新已就绪',
|
||||
restarting: '正在重启并更新',
|
||||
completed: '更新完成',
|
||||
};
|
||||
|
||||
// Status is authoritative; old progress fields must not claim completion.
|
||||
export function upgradeStatusLabel(job: UpgradeJob): string {
|
||||
if (job.status === 'ready') return '下载完成,等待重启';
|
||||
if (job.status === 'completed') return '升级成功';
|
||||
if (job.status === 'failed') return '更新失败';
|
||||
if (activeStatuses.includes(job.status)) {
|
||||
return !['ready', 'completed'].includes(job.phase || '')
|
||||
? phaseLabels[job.phase || ''] || '正在准备更新'
|
||||
: '正在准备更新';
|
||||
}
|
||||
return '检查更新';
|
||||
}
|
||||
|
||||
// The sidebar and settings share one poller and one mutation state.
|
||||
export function createUpgradeStore() {
|
||||
let snapshot = {
|
||||
|
||||
@@ -346,6 +346,11 @@ select:-webkit-autofill:focus {
|
||||
|
||||
.ant-pro-global-header {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03);
|
||||
|
||||
.ant-pro-global-header-collapsed-button {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item.ant-pro-sider-collapsed-button {
|
||||
|
||||
@@ -734,6 +734,8 @@
|
||||
"从开始至load总耗时": "Total load time: {duration} ms",
|
||||
"正在解压更新包": "Extracting update",
|
||||
"正在校验更新包": "Verifying update",
|
||||
"正在预启动验证": "Verifying backend startup",
|
||||
"正在修复运行依赖": "Repairing runtime dependencies",
|
||||
"正在准备依赖": "Preparing dependencies",
|
||||
"更新已就绪": "Update ready",
|
||||
"正在重启并更新": "Restarting and updating",
|
||||
@@ -744,6 +746,7 @@
|
||||
"更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.",
|
||||
"更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.",
|
||||
"下载构建产物": "Downloading application files",
|
||||
"下载更新包": "Downloading update package",
|
||||
"下载源码": "Downloading source files",
|
||||
"正在获取下载大小": "Total size unavailable",
|
||||
"查看更新日志": "View update log",
|
||||
|
||||
@@ -734,6 +734,8 @@
|
||||
"从开始至load总耗时": "从开始至 load 总耗时: {duration} ms",
|
||||
"正在解压更新包": "正在解压更新包",
|
||||
"正在校验更新包": "正在校验更新包",
|
||||
"正在预启动验证": "正在预启动验证",
|
||||
"正在修复运行依赖": "正在修复运行依赖",
|
||||
"正在准备依赖": "正在准备依赖",
|
||||
"更新已就绪": "更新已就绪",
|
||||
"正在重启并更新": "正在重启并更新",
|
||||
@@ -744,6 +746,7 @@
|
||||
"更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本",
|
||||
"更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板",
|
||||
"下载构建产物": "下载构建产物",
|
||||
"下载更新包": "下载更新包",
|
||||
"下载源码": "下载源码",
|
||||
"正在获取下载大小": "正在获取下载大小",
|
||||
"查看更新日志": "查看更新日志",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, Modal, Progress, Space, Typography, message } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import useUpgrade, {
|
||||
activeStatuses,
|
||||
phaseLabels,
|
||||
upgradeStatusLabel,
|
||||
reloadKey,
|
||||
upgradeStore,
|
||||
} from '@/hooks/useUpgrade';
|
||||
@@ -100,14 +100,7 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
||||
onOk: () => reloadSystem(true),
|
||||
});
|
||||
|
||||
const label =
|
||||
job.status === 'failed'
|
||||
? intl.get('更新失败')
|
||||
: ready
|
||||
? intl.get('下载完成,等待重启')
|
||||
: completed
|
||||
? intl.get('升级成功')
|
||||
: intl.get(phaseLabels[job.phase || ''] || '正在准备更新');
|
||||
const label = intl.get(upgradeStatusLabel(job));
|
||||
const hasJob = job.status !== 'idle' && job.status !== 'loading';
|
||||
const actions = !active && (
|
||||
<Space wrap>
|
||||
@@ -167,7 +160,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
|
||||
<div className="upgrade-progress-meta">
|
||||
<Typography.Text type="secondary">
|
||||
{intl.get(
|
||||
job.artifact === 'qinglong-static'
|
||||
job.artifact === 'qinglong-bundle'
|
||||
? '下载更新包'
|
||||
: job.artifact === 'qinglong-static'
|
||||
? '下载构建产物'
|
||||
: '下载源码',
|
||||
)}
|
||||
|
||||
+24
-22
@@ -1,27 +1,29 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import browserType from './index';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useCtx = () => {
|
||||
const [width, setWidth] = useState('100%');
|
||||
const [marginLeft, setMarginLeft] = useState(0);
|
||||
const [marginTop, setMarginTop] = useState(-48);
|
||||
const [isPhone, setIsPhone] = useState(false);
|
||||
const { platform } = useMemo(() => browserType(), []);
|
||||
// Match ProLayout's xs/sm breakpoint, including narrow desktop windows.
|
||||
const [isPhone, setIsPhone] = useState(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(max-width: 767.99px)').matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (platform === 'mobile' && document.body.offsetWidth < 768) {
|
||||
setWidth('auto');
|
||||
setMarginLeft(0);
|
||||
setMarginTop(0);
|
||||
setIsPhone(true);
|
||||
document.body.setAttribute('data-mode', 'phone');
|
||||
} else {
|
||||
setWidth('100%');
|
||||
setMarginLeft(0);
|
||||
setMarginTop(-48);
|
||||
setIsPhone(false);
|
||||
document.body.setAttribute('data-mode', 'desktop');
|
||||
const media = window.matchMedia('(max-width: 767.99px)');
|
||||
const update = () => {
|
||||
setIsPhone(media.matches);
|
||||
document.body.setAttribute(
|
||||
'data-mode',
|
||||
media.matches ? 'phone' : 'desktop',
|
||||
);
|
||||
};
|
||||
update();
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}
|
||||
media.addListener(update);
|
||||
return () => media.removeListener(update);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -31,9 +33,9 @@ export const useCtx = () => {
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 20,
|
||||
marginTop,
|
||||
width,
|
||||
marginLeft,
|
||||
marginTop: isPhone ? 0 : -48,
|
||||
width: isPhone ? 'auto' : '100%',
|
||||
marginLeft: 0,
|
||||
} as any,
|
||||
isPhone,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const execute = promisify(execFile);
|
||||
const {
|
||||
manualUpgradeFixture,
|
||||
} = require('../helpers/manual-upgrade-fixture.cjs');
|
||||
const jobs = require('../../cli/src/internal/maintenance/panelUpgrade.cjs');
|
||||
|
||||
for (const wrongVersion of [false, true]) {
|
||||
test(`Bash reload consumes offline readiness and records the actual result; wrong version=${wrongVersion}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t, wrongVersion);
|
||||
const operation = execute(
|
||||
process.execPath,
|
||||
[path.resolve('shell/upgrade.cjs'), 'reload'],
|
||||
{ env: f.env, timeout: 15000 },
|
||||
);
|
||||
if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/);
|
||||
else await operation;
|
||||
const state = await f.state();
|
||||
assert.equal(state.status, wrongVersion ? 'failed' : 'completed');
|
||||
assert.equal(state.action, 'apply');
|
||||
assert.notEqual(state.id, 'offline-prepared');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
wrongVersion ? 'old backend' : 'new backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
|
||||
'preserve configuration',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
if (wrongVersion)
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
else
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'upgrade-ready-develop.json')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('manual application holds the launch lock and does not allow a competing panel job', async (t) => {
|
||||
const f = await manualUpgradeFixture(t);
|
||||
await assert.rejects(
|
||||
jobs.withPreparedJob(f.tmp, f.env, f.selected, async (job) => {
|
||||
assert.equal((await f.state()).status, 'running');
|
||||
await assert.rejects(
|
||||
jobs.startJob({ root: f.root, tmp: f.tmp, env: f.env }, 'apply'),
|
||||
/已有更新/,
|
||||
);
|
||||
await job.onStop();
|
||||
assert.equal((await f.state()).status, 'restarting');
|
||||
throw new Error('cancel manual operation');
|
||||
}),
|
||||
/cancel manual operation/,
|
||||
);
|
||||
assert.equal((await f.state()).status, 'failed');
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
manualUpgradeFixture,
|
||||
} = require('../helpers/manual-upgrade-fixture.cjs');
|
||||
const artifacts = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
const { installBundle } = require('../../shell/install-bundle.cjs');
|
||||
|
||||
for (const scenario of ['cancel-new-start', 'both-starts-fail'])
|
||||
test(`offline application restores files and records failure: ${scenario}`, async (t) => {
|
||||
const f = await manualUpgradeFixture(t);
|
||||
t.mock.method(artifacts, 'stageUpgrade', async () => f.selected);
|
||||
const journal = path.join(f.root, 'lifecycle.jsonl');
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'lifecycle.cjs'),
|
||||
`
|
||||
const fs=require('node:fs'),path=require('node:path');
|
||||
const root=process.env.QL_DIR,op=process.argv[2];
|
||||
const version=fs.readFileSync(path.join(root,'static/build/app.js'),'utf8');
|
||||
fs.appendFileSync(${JSON.stringify(
|
||||
journal,
|
||||
)},JSON.stringify({op,version})+'\\n');
|
||||
if(op==='start') {
|
||||
if(${JSON.stringify(scenario)}==='both-starts-fail') process.exit(9);
|
||||
if(version==='new backend') {
|
||||
process.kill(Number(process.env.TEST_INSTALLER_PID),'SIGTERM');
|
||||
setTimeout(()=>{},10000);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`
|
||||
import_config(){ :; }
|
||||
delete_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" stop; }
|
||||
reload_pm2(){ "$TEST_NODE" "$QL_DIR/lifecycle.cjs" start; }
|
||||
`,
|
||||
);
|
||||
await assert.rejects(
|
||||
installBundle(
|
||||
['--root', f.root, '--file', path.join(f.root, 'unused.tar.gz')],
|
||||
{
|
||||
...f.env,
|
||||
TEST_NODE: process.execPath,
|
||||
TEST_INSTALLER_PID: String(process.pid),
|
||||
},
|
||||
),
|
||||
scenario === 'cancel-new-start' ? /离线升级已中断/ : /failed/,
|
||||
);
|
||||
const calls = (await fs.readFile(journal, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse);
|
||||
assert.deepEqual(calls, [
|
||||
{ op: 'stop', version: 'old backend' },
|
||||
{ op: 'start', version: 'new backend' },
|
||||
{ op: 'stop', version: 'new backend' },
|
||||
{ op: 'start', version: 'old backend' },
|
||||
]);
|
||||
assert.equal((await f.state()).status, 'failed');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'),
|
||||
'old backend',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, '.env'), 'utf8'),
|
||||
'preserve configuration',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'),
|
||||
'preserve data',
|
||||
);
|
||||
await fs.access(path.join(f.tmp, 'upgrade-ready-develop.json'));
|
||||
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
assert.ok(
|
||||
!(await fs.readdir(f.root)).some((n) => n.includes('.ql-backup-')),
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const tar = require('tar');
|
||||
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const artifacts = require('../../shell/upgrade.cjs');
|
||||
|
||||
for (const failure of ['prestart', 'repair'])
|
||||
test(`failed ${failure} preserves the installed dependencies and previous ready pointer`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
await fs.mkdir(path.join(f.root, 'node_modules'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'node_modules/keep'),
|
||||
'old dependency',
|
||||
);
|
||||
let repairs = 0;
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: {
|
||||
QL_BRANCH: 'develop',
|
||||
QL_UPGRADE_FILE: f.archive,
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
},
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {},
|
||||
prestart: async () => {
|
||||
if (failure === 'prestart') throw new Error('SQLITE_CANTOPEN');
|
||||
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
});
|
||||
},
|
||||
repair: async (source) => {
|
||||
repairs++;
|
||||
await fs.writeFile(
|
||||
path.join(source, 'node_modules/partial-repair'),
|
||||
'partial',
|
||||
);
|
||||
throw new Error('registry unavailable');
|
||||
},
|
||||
}),
|
||||
failure === 'prestart' ? /SQLITE_CANTOPEN/ : /registry unavailable/,
|
||||
);
|
||||
assert.equal(repairs, failure === 'prestart' ? 0 : 1);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'node_modules/keep'), 'utf8'),
|
||||
'old dependency',
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
|
||||
});
|
||||
|
||||
test('ready is published only after prestart; dependency failure repairs once and keeps application hashes', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
let probes = 0,
|
||||
repairs = 0,
|
||||
starts = 0;
|
||||
const options = {
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: {
|
||||
QL_BRANCH: 'develop',
|
||||
QL_UPGRADE_FILE: f.archive,
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
},
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {
|
||||
if (++probes === 1)
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'dlopen node_modules/sqlite3/node.node: invalid ELF header',
|
||||
),
|
||||
{ code: 'ERR_DLOPEN_FAILED' },
|
||||
);
|
||||
},
|
||||
repair: async (source) => {
|
||||
repairs++;
|
||||
await fs.writeFile(
|
||||
path.join(source, 'node_modules/sqlite3/repaired'),
|
||||
'rebuilt for this device',
|
||||
);
|
||||
},
|
||||
prestart: async () => {
|
||||
starts++;
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
},
|
||||
};
|
||||
const selected = await bundle.stageBundle(options);
|
||||
assert.equal(repairs, 1);
|
||||
assert.equal(probes, 2);
|
||||
assert.equal(starts, 1);
|
||||
const receipt = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(selected.source), 'prestart.json'),
|
||||
),
|
||||
);
|
||||
assert.ok(receipt.completedAt);
|
||||
assert.ok(
|
||||
receipt.dependencyFiles[
|
||||
'source/node_modules/.pnpm/sqlite/node_modules/sqlite3/repaired'
|
||||
],
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(selected.source, 'version.yaml'),
|
||||
'version: 9.9.9',
|
||||
);
|
||||
await assert.rejects(bundle.verifyStagedBundle(selected), /checksums/);
|
||||
});
|
||||
|
||||
test('offline dependency failure never repairs by default; a failed retry keeps old readiness', async (t) => {
|
||||
const f = await fixture(t),
|
||||
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
let repairs = 0,
|
||||
starts = 0;
|
||||
const options = {
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {},
|
||||
prestart: async () => {
|
||||
starts++;
|
||||
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
});
|
||||
},
|
||||
repair: async () => {
|
||||
repairs++;
|
||||
},
|
||||
run: () => assert.fail('offline must not access network'),
|
||||
};
|
||||
await assert.rejects(
|
||||
bundle.stageBundle(options),
|
||||
/no network access attempted/,
|
||||
);
|
||||
assert.equal(repairs, 0);
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
...options,
|
||||
env: { ...options.env, QL_UPGRADE_REPAIR_DEPS: '1' },
|
||||
}),
|
||||
/Cannot find module/,
|
||||
);
|
||||
assert.equal(repairs, 1);
|
||||
assert.equal(starts, 3);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
assert.deepEqual(await fs.readdir(f.tmp), ['upgrade-ready-develop.json']);
|
||||
});
|
||||
|
||||
test('dependency repair cannot change verified source or static files', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let starts = 0;
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: {
|
||||
QL_BRANCH: 'develop',
|
||||
QL_UPGRADE_FILE: f.archive,
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
},
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {
|
||||
throw Object.assign(new Error("Cannot find module 'decimal.js'"), {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
});
|
||||
},
|
||||
repair: async (source) =>
|
||||
fs.writeFile(path.join(source, 'version.yaml'), 'version: 9.9.9'),
|
||||
prestart: async () => {
|
||||
starts++;
|
||||
},
|
||||
}),
|
||||
/checksums/,
|
||||
);
|
||||
assert.equal(starts, 0);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const preload = path.join(directory, 'armv6.cjs');
|
||||
const observed = path.join(directory, 'child.json');
|
||||
await fs.writeFile(
|
||||
preload,
|
||||
`if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify(
|
||||
observed,
|
||||
)},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`,
|
||||
);
|
||||
const { execFile } = require('node:child_process');
|
||||
const result = await new Promise((resolve) =>
|
||||
execFile(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../shell/install-bundle.cjs')],
|
||||
{
|
||||
timeout: 10000,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--require ' + JSON.stringify(preload),
|
||||
},
|
||||
},
|
||||
(error, stdout, stderr) => resolve({ error, stdout, stderr }),
|
||||
),
|
||||
);
|
||||
assert.equal(result.error.code, 1);
|
||||
const child = JSON.parse(await fs.readFile(observed));
|
||||
assert.ok(child.args.includes('--jitless'));
|
||||
assert.equal(child.execPath, process.execPath);
|
||||
assert.match(result.stderr, /Usage: node install-bundle.cjs/);
|
||||
});
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../helpers/upgrade-fixture.cjs');
|
||||
const {
|
||||
releaseIndex,
|
||||
platforms,
|
||||
} = require('../../scripts/publish-upgrade-bundles.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bundle-test-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, 'installed'),
|
||||
tmp = path.join(root, '.tmp');
|
||||
const payload = path.join(directory, 'qinglong');
|
||||
const source = path.join(payload, 'source'),
|
||||
staticRoot = path.join(payload, 'static');
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
await fs.mkdir(
|
||||
path.join(source, 'node_modules/.pnpm/sqlite/node_modules/sqlite3'),
|
||||
{ recursive: true },
|
||||
);
|
||||
await fs.symlink(
|
||||
'.pnpm/sqlite/node_modules/sqlite3',
|
||||
path.join(source, 'node_modules/sqlite3'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(source, 'node_modules/sqlite3/package.json'),
|
||||
'{"name":"sqlite3"}',
|
||||
);
|
||||
await fs.mkdir(path.join(source, 'sample'));
|
||||
await fs.writeFile(path.join(source, 'sample/config.sample.sh'), '# config');
|
||||
await fs.writeFile(
|
||||
path.join(source, 'package.json'),
|
||||
'{"version":"2.24.0","qinglongUpgradeFormat":2}',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'package.json'),
|
||||
'{"qinglongUpgradeFormat":2}',
|
||||
);
|
||||
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
|
||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'backend');
|
||||
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.mkdir(path.join(staticRoot, 'build/bootstrap'));
|
||||
await fs.writeFile(
|
||||
path.join(staticRoot, 'build/bootstrap/upgradeProbe.js'),
|
||||
`
|
||||
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
|
||||
if(process.env.NODE_OPTIONS || process.env.NODE_PATH || process.env.QL_CLI_ROOT)throw new Error('Leaked host environment');
|
||||
const info=JSON.parse(fs.readFileSync(path.join(__dirname,'../../build-info.json')));
|
||||
process.send({type:'upgrade-probe',ok:true,build:{version:'2.24.0',sourceCommit:info.sourceCommit,
|
||||
backendSha256:crypto.createHash('sha256').update(fs.readFileSync(path.join(__dirname,'../app.js'))).digest('hex')}});
|
||||
process.disconnect();
|
||||
`,
|
||||
);
|
||||
writeManifest(source, staticRoot);
|
||||
const target = await bundle.runtimeTarget();
|
||||
const manifest = {
|
||||
format: 2,
|
||||
sourceCommit,
|
||||
lockfileSha256: bundle.sha256(
|
||||
await fs.readFile(path.join(source, 'pnpm-lock.yaml')),
|
||||
),
|
||||
target: { ...target, libcMinimum: target.libcVersion },
|
||||
nodeMinimum: '20.18.1',
|
||||
nodeApi: 6,
|
||||
files: await bundle.inventory({ source, static: staticRoot }),
|
||||
};
|
||||
const archive = path.join(directory, 'package.tar.gz');
|
||||
const pack = async () => {
|
||||
await fs.writeFile(
|
||||
path.join(payload, 'bundle.json'),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
await tar.c(
|
||||
{
|
||||
file: archive,
|
||||
cwd: directory,
|
||||
gzip: true,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
},
|
||||
['qinglong'],
|
||||
);
|
||||
};
|
||||
await pack();
|
||||
return {
|
||||
directory,
|
||||
root,
|
||||
tmp,
|
||||
source,
|
||||
staticRoot,
|
||||
target,
|
||||
manifest,
|
||||
archive,
|
||||
pack,
|
||||
};
|
||||
}
|
||||
|
||||
test('online and offline stage the same complete payload, preserve pnpm links and never install dependencies', async (t) => {
|
||||
const f = await fixture(t),
|
||||
bytes = await fs.readFile(f.archive),
|
||||
progress = [];
|
||||
const entry = {
|
||||
size: bytes.length,
|
||||
sha256: bundle.sha256(bytes),
|
||||
url: 'https://example.invalid/immutable.tar.gz',
|
||||
};
|
||||
let calls = 0,
|
||||
probes = 0;
|
||||
const run = async (program, args) => {
|
||||
assert.equal(program, 'curl');
|
||||
calls++;
|
||||
const output = args[args.indexOf('--output') + 1];
|
||||
if (args.at(-1).endsWith('latest.json'))
|
||||
await fs.writeFile(
|
||||
output,
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit,
|
||||
packages: { [bundle.targetKey(f.target)]: entry },
|
||||
}),
|
||||
);
|
||||
else {
|
||||
await fs.writeFile(output, bytes);
|
||||
await fs.writeFile(
|
||||
args[args.indexOf('--dump-header') + 1],
|
||||
'HTTP/2 200 OK\r\n\r\n',
|
||||
);
|
||||
}
|
||||
};
|
||||
const options = {
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: { QL_BRANCH: 'develop' },
|
||||
run,
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
download: artifacts.downloadWithProgress,
|
||||
probe: async () => {
|
||||
probes++;
|
||||
},
|
||||
onProgress: async (p) => progress.push(p),
|
||||
};
|
||||
const online = await bundle.stageBundle(options);
|
||||
const first = await bundle.inventory(online);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(probes, 1);
|
||||
assert.ok(progress.some((p) => p.percent === 100));
|
||||
assert.ok(progress.some((p) => p.percent === 0));
|
||||
const offline = await bundle.stageBundle({
|
||||
...options,
|
||||
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
|
||||
run: () => assert.fail('offline network access'),
|
||||
});
|
||||
assert.equal(probes, 2);
|
||||
assert.deepEqual(await bundle.inventory(offline), first);
|
||||
assert.equal(
|
||||
await fs.readlink(path.join(offline.source, 'node_modules/sqlite3')),
|
||||
'.pnpm/sqlite/node_modules/sqlite3',
|
||||
);
|
||||
await assert.rejects(fs.access(online.source));
|
||||
});
|
||||
|
||||
test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains the existing Node requirement', async (t) => {
|
||||
const f = await fixture(t),
|
||||
target = {
|
||||
platform: 'linux',
|
||||
arch: 'armv6',
|
||||
libc: 'musl',
|
||||
libcVersion: '1.2.5',
|
||||
};
|
||||
const manifest = {
|
||||
...f.manifest,
|
||||
target: { ...target, libcMinimum: '1.2.4' },
|
||||
};
|
||||
bundle.assertCompatible(manifest, target, '20.20.2');
|
||||
const glibcTarget = {
|
||||
...target,
|
||||
libc: 'glibc',
|
||||
libcVersion: '2.31',
|
||||
endianness: 'LE',
|
||||
armFloatAbi: 'hard',
|
||||
};
|
||||
const glibcManifest = {
|
||||
...manifest,
|
||||
target: { ...glibcTarget, libcMinimum: '2.31' },
|
||||
};
|
||||
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
|
||||
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc');
|
||||
assert.throws(
|
||||
() =>
|
||||
bundle.assertCompatible(glibcManifest, {
|
||||
...glibcTarget,
|
||||
libcVersion: '2.30',
|
||||
}),
|
||||
/requires glibc/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
bundle.assertCompatible(glibcManifest, {
|
||||
...glibcTarget,
|
||||
armFloatAbi: 'soft',
|
||||
}),
|
||||
/floating-point ABI mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }),
|
||||
/platform mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => bundle.assertCompatible(manifest, { ...target, libc: 'glibc' }),
|
||||
/platform mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => bundle.assertCompatible(manifest, target, '18.20.0'),
|
||||
/requires Node/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
bundle.assertCompatible(manifest, { ...target, libcVersion: '1.1.24' }),
|
||||
/requires musl/,
|
||||
);
|
||||
});
|
||||
|
||||
test('dependency tampering and payload link escapes are rejected', async (t) => {
|
||||
const f = await fixture(t),
|
||||
selected = { source: f.source, static: f.staticRoot };
|
||||
await bundle.verifyPayload(selected, f.manifest, f.target);
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/package.json'),
|
||||
'{"tampered":true}',
|
||||
);
|
||||
await assert.rejects(
|
||||
bundle.verifyPayload(selected, f.manifest, f.target),
|
||||
/checksums/,
|
||||
);
|
||||
await fs.symlink('/etc/passwd', path.join(f.source, 'outside'));
|
||||
await assert.rejects(bundle.inventory(selected), /escapes/);
|
||||
});
|
||||
|
||||
test('archive traversal, absolute links and writes through internal symlinks are rejected before extraction', async (t) => {
|
||||
const f = await fixture(t),
|
||||
dest = path.join(f.directory, 'extract');
|
||||
await fs.mkdir(dest);
|
||||
await fs.symlink('/etc', path.join(f.directory, 'qinglong/bad'));
|
||||
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
|
||||
'qinglong/bad',
|
||||
]);
|
||||
await assert.rejects(bundle.extractArchive(f.archive, dest), /symlink/);
|
||||
await tar.c(
|
||||
{ cwd: f.directory, file: f.archive, gzip: true, prefix: '../outside' },
|
||||
['qinglong/source/package.json'],
|
||||
);
|
||||
await assert.rejects(bundle.extractArchive(f.archive, dest), /Unsafe/);
|
||||
await fs.mkdir(path.join(f.directory, 'qinglong/real'));
|
||||
await fs.writeFile(path.join(f.directory, 'qinglong/real/file'), 'value');
|
||||
await fs.symlink('real', path.join(f.directory, 'qinglong/link'));
|
||||
await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [
|
||||
'qinglong/link',
|
||||
'qinglong/link/file',
|
||||
]);
|
||||
await assert.rejects(
|
||||
bundle.extractArchive(f.archive, dest),
|
||||
/through a symlink/,
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(dest), []);
|
||||
});
|
||||
|
||||
test('an incomplete or corrupt platform download cannot replace an existing ready pointer', async (t) => {
|
||||
const f = await fixture(t),
|
||||
pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-ready');
|
||||
await assert.rejects(
|
||||
bundle.stageBundle({
|
||||
root: f.root,
|
||||
tmp: f.tmp,
|
||||
env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive },
|
||||
verifyStatic: artifacts.verifyUpgrade,
|
||||
probe: async () => {
|
||||
throw new Error('native addon failed');
|
||||
},
|
||||
}),
|
||||
/native addon failed/,
|
||||
);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready');
|
||||
assert.equal(
|
||||
(await fs.readdir(f.tmp)).filter((n) => n.startsWith('upgrade-')).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishing requires every platform and validates all immutable archive hashes', async (t) => {
|
||||
const f = await fixture(t),
|
||||
repository = 'whyour/qinglong';
|
||||
assert.throws(() => releaseIndex(f.directory, sourceCommit, repository));
|
||||
for (const target of platforms) {
|
||||
const filename = `qinglong-${sourceCommit}-${target}.tar.gz`,
|
||||
bytes = Buffer.from(target);
|
||||
await fs.writeFile(path.join(f.directory, filename), bytes);
|
||||
await fs.writeFile(
|
||||
path.join(f.directory, filename + '.json'),
|
||||
JSON.stringify({
|
||||
format: 2,
|
||||
sourceCommit,
|
||||
target,
|
||||
filename,
|
||||
size: bytes.length,
|
||||
sha256: bundle.sha256(bytes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const index = releaseIndex(f.directory, sourceCommit, repository);
|
||||
assert.equal(Object.keys(index.packages).length, 13);
|
||||
const armv6 = path.join(
|
||||
f.directory,
|
||||
`qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`,
|
||||
);
|
||||
const armv6Bytes = await fs.readFile(armv6);
|
||||
await fs.rm(armv6);
|
||||
assert.throws(
|
||||
() => releaseIndex(f.directory, sourceCommit, repository),
|
||||
/ENOENT/,
|
||||
);
|
||||
await fs.writeFile(armv6, armv6Bytes);
|
||||
await fs.appendFile(
|
||||
path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`),
|
||||
'corrupt',
|
||||
);
|
||||
assert.throws(
|
||||
() => releaseIndex(f.directory, sourceCommit, repository),
|
||||
/Unverified/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fresh offline installation needs no package manager, keeps Node and publishes a complete directory', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.source, '.env.example'), 'QlPort=5700\n');
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
`exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};`,
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
const destination = path.join(f.directory, 'fresh'),
|
||||
node = process.execPath;
|
||||
await require('../../shell/install-bundle.cjs').installBundle(
|
||||
['--root', destination, '--file', f.archive, '--install'],
|
||||
{ ...process.env, PATH: '', QL_BRANCH: 'develop' },
|
||||
);
|
||||
assert.equal(process.execPath, node);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, '.env'), 'utf8'),
|
||||
'QlPort=5700\n',
|
||||
);
|
||||
assert.equal(
|
||||
(await fs.stat(path.join(destination, '.env'))).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, 'static/build/app.js'), 'utf8'),
|
||||
'backend',
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await fs.stat(path.join(destination, 'node_modules/sqlite3/package.json'))
|
||||
).isFile(),
|
||||
);
|
||||
assert.equal(
|
||||
(await fs.readdir(f.directory)).filter((n) =>
|
||||
n.startsWith('.qinglong-install-'),
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
await fs.writeFile(path.join(destination, 'keep-user-data'), 'keep');
|
||||
await assert.rejects(
|
||||
require('../../shell/install-bundle.cjs').installBundle([
|
||||
'--root',
|
||||
destination,
|
||||
'--file',
|
||||
f.archive,
|
||||
'--install',
|
||||
]),
|
||||
/empty installation directory/,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destination, 'keep-user-data'), 'utf8'),
|
||||
'keep',
|
||||
);
|
||||
});
|
||||
|
||||
test('offline staging replaces stale panel readiness and refuses an active online job', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};',
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
await fs.mkdir(path.join(f.root, 'shell'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
'import_config() { :; }\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/env.sh'),
|
||||
'export QL_BRANCH=develop\n',
|
||||
);
|
||||
const stateFile = path.join(f.tmp, 'panel-upgrade.json');
|
||||
await fs.writeFile(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
id: 'previous-online-job',
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
}),
|
||||
);
|
||||
const install = require('../../shell/install-bundle.cjs').installBundle;
|
||||
await install(['--root', f.root, '--file', f.archive, '--download-only']);
|
||||
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
|
||||
assert.equal(state.status, 'ready');
|
||||
assert.equal(state.sourceCommit, sourceCommit);
|
||||
assert.notEqual(state.id, 'previous-online-job');
|
||||
assert.equal(state.version, '2.24.0');
|
||||
const pointer = await fs.readFile(
|
||||
path.join(f.tmp, 'upgrade-ready-develop.json'),
|
||||
'utf8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
id: 'live-online-job',
|
||||
status: 'running',
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
install(['--root', f.root, '--file', f.archive, '--download-only']),
|
||||
/已有更新/,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(await fs.readFile(stateFile, 'utf8')).id,
|
||||
'live-online-job',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.tmp, 'upgrade-ready-develop.json'), 'utf8'),
|
||||
pointer,
|
||||
);
|
||||
});
|
||||
|
||||
test('interrupting offline staging records failure, preserves prior readiness and releases both locks', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const { spawn } = require('node:child_process');
|
||||
await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n');
|
||||
await fs.writeFile(
|
||||
path.join(f.source, 'node_modules/sqlite3/index.js'),
|
||||
'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){setTimeout(()=>cb(null,{value:42}),10000)}close(){}};',
|
||||
);
|
||||
for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) {
|
||||
const dir = path.join(f.source, 'node_modules', name);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};');
|
||||
}
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
await fs.mkdir(path.join(f.root, 'shell'));
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
'import_config() { :; }\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/env.sh'),
|
||||
'export QL_BRANCH=develop\n',
|
||||
);
|
||||
const pointer = path.join(f.tmp, 'upgrade-ready-develop.json');
|
||||
await fs.writeFile(pointer, 'previous-good-readiness');
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, '../../shell/install-bundle.cjs'),
|
||||
'--root',
|
||||
f.root,
|
||||
'--file',
|
||||
f.archive,
|
||||
'--download-only',
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
child.once('exit', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
let seen = false;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const state = await fs
|
||||
.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8')
|
||||
.then(JSON.parse)
|
||||
.catch(() => ({}));
|
||||
if (state.phase === 'dependencies') {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
assert.ok(seen);
|
||||
child.kill('SIGTERM');
|
||||
assert.equal(await exited, 1);
|
||||
assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-good-readiness');
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8'),
|
||||
).status,
|
||||
'failed',
|
||||
);
|
||||
await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock')));
|
||||
await assert.rejects(
|
||||
fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')),
|
||||
);
|
||||
});
|
||||
|
||||
test('portable release archives preserve the permission manifest after normalizing image scripts', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const script = path.join(f.source, 'sample/config.sample.sh');
|
||||
await fs.chmod(script, 0o777);
|
||||
const { normalizeModes } = require('../../scripts/build-upgrade-bundle.cjs');
|
||||
await normalizeModes(f.source);
|
||||
await normalizeModes(f.staticRoot);
|
||||
assert.equal((await fs.stat(script)).mode & 0o777, 0o755);
|
||||
f.manifest.files = await bundle.inventory({
|
||||
source: f.source,
|
||||
static: f.staticRoot,
|
||||
});
|
||||
await f.pack();
|
||||
const extracted = path.join(f.directory, 'roundtrip');
|
||||
await fs.mkdir(extracted);
|
||||
await bundle.extractArchive(f.archive, extracted);
|
||||
const selected = {
|
||||
source: path.join(extracted, 'qinglong/source'),
|
||||
static: path.join(extracted, 'qinglong/static'),
|
||||
};
|
||||
await bundle.verifyPayload(selected, f.manifest, f.target);
|
||||
await fs.chmod(path.join(selected.source, 'sample/config.sample.sh'), 0o777);
|
||||
await assert.rejects(
|
||||
bundle.verifyPayload(selected, f.manifest, f.target),
|
||||
/checksums/,
|
||||
);
|
||||
});
|
||||
|
||||
test('full dependency bundles get a bounded slow-link download budget instead of the old ten-minute limit', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let observed;
|
||||
await assert.rejects(
|
||||
artifacts.downloadWithProgress(
|
||||
async (program, args) => {
|
||||
assert.equal(program, 'curl');
|
||||
observed = Number(args[args.indexOf('--max-time') + 1]);
|
||||
assert.ok(observed > 600 && observed <= 7200);
|
||||
assert.equal(args[args.indexOf('--speed-limit') + 1], '1024');
|
||||
throw new Error('stop before transfer');
|
||||
},
|
||||
path.join(f.directory, 'slow.tar.gz'),
|
||||
'https://example.invalid/package.tar.gz',
|
||||
'qinglong-bundle',
|
||||
async () => {},
|
||||
true,
|
||||
{ size: 24 * 1024 * 1024, sha256: 'a'.repeat(64) },
|
||||
),
|
||||
/stop before transfer/,
|
||||
);
|
||||
assert.ok(observed);
|
||||
});
|
||||
@@ -23,6 +23,9 @@ function fixture() {
|
||||
let invalidations = 0;
|
||||
const module = { exports: {} };
|
||||
const localRequire = createRequire(path.resolve('back/app.ts'));
|
||||
const middlewareRequire = createRequire(
|
||||
path.resolve('back/loaders/httpApplication.ts'),
|
||||
);
|
||||
const mocks = {
|
||||
'./config': { cors: { origin: ['http://localhost'] } },
|
||||
'./loaders/logger': { error() {}, warn() {}, info() {} },
|
||||
@@ -37,6 +40,33 @@ function fixture() {
|
||||
new Function('require', 'module', 'exports', 'process', compiled)(
|
||||
(name) => {
|
||||
required.push(name);
|
||||
if (name === './loaders/httpApplication') {
|
||||
const helper = { exports: {} };
|
||||
const code = ts.transpileModule(
|
||||
fs.readFileSync('back/loaders/httpApplication.ts', 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
new Function('require', 'module', 'exports', code)(
|
||||
(dependency) => {
|
||||
required.push(dependency);
|
||||
const alias = dependency.startsWith('../')
|
||||
? './' + dependency.slice(3)
|
||||
: dependency;
|
||||
return Object.hasOwn(mocks, alias)
|
||||
? mocks[alias]
|
||||
: middlewareRequire(dependency);
|
||||
},
|
||||
helper,
|
||||
helper.exports,
|
||||
);
|
||||
return helper.exports;
|
||||
}
|
||||
return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name);
|
||||
},
|
||||
module,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
|
||||
const preparation = startup.slice(
|
||||
startup.indexOf('\nfix_config\n'),
|
||||
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
|
||||
);
|
||||
for (const fails of [false, true]) {
|
||||
test(`manual startup ${
|
||||
fails
|
||||
? 'stops on certificate failure'
|
||||
: 'awaits certificates before starting supervision'
|
||||
}`, (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-start-certs-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const module = path.join(root, 'static/build/config/grpcCerts.js');
|
||||
fs.mkdirSync(path.dirname(module), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
module,
|
||||
`exports.initGrpcCerts = () => new Promise((resolve, reject) => setTimeout(() => {
|
||||
if (process.env.QA_CERT_FAILURE === '1') return reject(new Error('certificate preparation failed'));
|
||||
require('node:fs').writeFileSync(require('node:path').join(process.env.QL_DIR, 'certs-ready'), 'ready');
|
||||
resolve();
|
||||
}, 50));`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-c',
|
||||
`set -e
|
||||
fix_config(){ :; }
|
||||
pm2(){ test -f "$QL_DIR/certs-ready"; touch "$QL_DIR/supervisor-started"; }
|
||||
${preparation}`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
QL_DIR: root,
|
||||
QA_CERT_FAILURE: fails ? '1' : '0',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, fails ? 1 : 0, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(root, 'supervisor-started')), !fails);
|
||||
if (fails) assert.match(result.stderr, /certificate preparation failed/);
|
||||
});
|
||||
}
|
||||
@@ -393,7 +393,10 @@ done
|
||||
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false',
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
|
||||
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
||||
// Bash SECONDS has one-second resolution. Two ticks guarantee a real
|
||||
// wait across a second boundary; a single tick may take only milliseconds.
|
||||
QL_MIRROR_RETRY_AFTER_SECONDS: '2',
|
||||
QL_MIRROR_WAIT_SECONDS: '8',
|
||||
QL_TEST_COUNT: countFile,
|
||||
QL_TEST_MIRROR: mirror,
|
||||
},
|
||||
@@ -403,6 +406,14 @@ done
|
||||
Date.now() - started >= 900,
|
||||
'notification should wait for the sync interval',
|
||||
);
|
||||
const retry = result.stdout.indexOf('Retrying the GitHub notification');
|
||||
assert.ok(retry >= 0, result.stdout);
|
||||
const elapsed = [
|
||||
...result.stdout
|
||||
.slice(0, retry)
|
||||
.matchAll(/Waiting for mirror \(attempt \d+, (\d+)\/8s\)/g),
|
||||
].at(-1);
|
||||
assert.ok(elapsed && Number(elapsed[1]) >= 2, result.stdout);
|
||||
assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n');
|
||||
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
|
||||
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -286,3 +286,46 @@ npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; }
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test('offline selection and worker ownership do not leak into the restarted backend', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`${await fs.readFile('shell/share.sh', 'utf8')}
|
||||
pm2() { if [ "$1" = startOrGracefulReload ]; then node "$QL_DIR/report-runtime.cjs"; fi; }
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'report-runtime.cjs'),
|
||||
`
|
||||
require('node:fs').writeFileSync(process.env.QL_DIR + '/runtime.json', JSON.stringify({
|
||||
file: process.env.QL_UPGRADE_FILE ?? null,
|
||||
repair: process.env.QL_UPGRADE_REPAIR_DEPS ?? null,
|
||||
launcher: process.env.QL_UPGRADE_LAUNCHER_PID ?? null,
|
||||
custom: process.env.QA_USER_SETTING,
|
||||
branch: process.env.QL_BRANCH,
|
||||
}));
|
||||
`,
|
||||
);
|
||||
const env = Object.freeze({
|
||||
...f.env,
|
||||
QL_UPGRADE_FILE: path.join(f.root, 'offline.tar.gz'),
|
||||
QL_UPGRADE_REPAIR_DEPS: '1',
|
||||
QL_UPGRADE_LAUNCHER_PID: '12345',
|
||||
QA_USER_SETTING: 'preserved',
|
||||
QL_BRANCH: 'develop',
|
||||
});
|
||||
await withLifecycleHooks(f.root, f.tmp, env, (hook) => hook('start'));
|
||||
assert.deepEqual(
|
||||
JSON.parse(await fs.readFile(path.join(f.root, 'runtime.json'))),
|
||||
{
|
||||
file: null,
|
||||
repair: null,
|
||||
launcher: null,
|
||||
custom: 'preserved',
|
||||
branch: 'develop',
|
||||
},
|
||||
);
|
||||
assert.equal(env.QL_UPGRADE_FILE, path.join(f.root, 'offline.tar.gz'));
|
||||
assert.equal(env.QL_UPGRADE_REPAIR_DEPS, '1');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
prestartBackend,
|
||||
dependencyFailure,
|
||||
probeLimits,
|
||||
repairDependencies,
|
||||
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
|
||||
|
||||
for (const exitCode of [0, 1])
|
||||
test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source'),
|
||||
statics = path.join(root, 'static'),
|
||||
survivor = path.join(root, 'survivor');
|
||||
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
||||
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
|
||||
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
|
||||
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
|
||||
await fs.writeFile(
|
||||
path.join(statics, 'build-info.json'),
|
||||
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
|
||||
`
|
||||
const fs=require('node:fs'),crypto=require('node:crypto');
|
||||
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
|
||||
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
|
||||
survivor,
|
||||
)},'escaped'),600);`,
|
||||
)}],{stdio:'inherit'});
|
||||
process.send({type:'upgrade-probe',ok:${
|
||||
exitCode === 0
|
||||
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
|
||||
`,
|
||||
);
|
||||
const operation = prestartBackend(
|
||||
{ source, static: statics },
|
||||
{ target: { arch: 'armv6' }, limits: { heapMb: 128, timeoutMs: 3000 } },
|
||||
);
|
||||
if (exitCode) await assert.rejects(operation, /simulated prestart failure/);
|
||||
else await operation;
|
||||
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
for (const phase of ['install', 'rebuild'])
|
||||
test(`dependency repair ${phase} failure preserves original dependencies, cleans workspace and uses configured proxy`, async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-failure-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source'),
|
||||
bin = path.join(root, 'bin');
|
||||
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
||||
await fs.mkdir(bin);
|
||||
await fs.writeFile(
|
||||
path.join(source, 'package.json'),
|
||||
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
|
||||
);
|
||||
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
|
||||
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
|
||||
const proxy = path.join(root, 'network.json');
|
||||
await fs.writeFile(
|
||||
path.join(bin, 'pnpm'),
|
||||
`#!${process.execPath}
|
||||
const fs=require('node:fs');
|
||||
if(process.argv[2]==='--version') {console.log('8.3.1');process.exit(0);}
|
||||
fs.writeFileSync(${JSON.stringify(
|
||||
proxy,
|
||||
)},JSON.stringify({https:process.env.HTTPS_PROXY,no:process.env.no_proxy,options:process.env.NODE_OPTIONS}));
|
||||
fs.mkdirSync('node_modules',{recursive:true});fs.writeFileSync('node_modules/partial','partial');
|
||||
if(process.argv[2]===${JSON.stringify(
|
||||
phase,
|
||||
)}) {console.error('simulated repair failure');process.exit(12);}
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await assert.rejects(
|
||||
repairDependencies(source, {
|
||||
target: { arch: 'armv6' },
|
||||
env: {
|
||||
PATH: bin,
|
||||
HTTPS_PROXY: 'http://127.0.0.1:7890',
|
||||
no_proxy: 'localhost,127.0.0.1',
|
||||
NODE_OPTIONS: '--require untrusted',
|
||||
},
|
||||
}),
|
||||
/simulated repair failure/,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
|
||||
'preserved',
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(path.join(source, 'node_modules')), [
|
||||
'old',
|
||||
]);
|
||||
assert.deepEqual((await fs.readdir(root)).sort(), [
|
||||
'bin',
|
||||
'network.json',
|
||||
'source',
|
||||
]);
|
||||
const network = JSON.parse(await fs.readFile(proxy));
|
||||
assert.equal(network.https, 'http://127.0.0.1:7890');
|
||||
assert.equal(network.no, 'localhost,127.0.0.1');
|
||||
assert.doesNotMatch(network.options, /untrusted/);
|
||||
});
|
||||
|
||||
test('cancelled dependency repair kills descendants and retains the original tree', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-cancel-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source'),
|
||||
bin = path.join(root, 'bin'),
|
||||
started = path.join(root, 'started'),
|
||||
survivor = path.join(root, 'survivor');
|
||||
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
||||
await fs.mkdir(bin);
|
||||
await fs.writeFile(
|
||||
path.join(source, 'package.json'),
|
||||
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
|
||||
);
|
||||
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
|
||||
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
|
||||
await fs.writeFile(
|
||||
path.join(bin, 'pnpm'),
|
||||
`#!${process.execPath}
|
||||
if(process.argv[2]==='--version'){console.log('8.3.1');process.exit(0);}
|
||||
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
|
||||
`process.on('SIGTERM',()=>{});require('node:fs').writeFileSync(${JSON.stringify(
|
||||
started,
|
||||
)},'started');setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
|
||||
survivor,
|
||||
)},'escaped'),1400);`,
|
||||
)}],{stdio:'inherit'});
|
||||
setInterval(()=>{},1000);
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const operation = repairDependencies(source, {
|
||||
target: { arch: 'armv6' },
|
||||
env: { PATH: bin },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejection = assert.rejects(operation, /cancel repair/);
|
||||
let seen = false;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
if (await fs.stat(started).catch(() => null)) {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
controller.abort(new Error('cancel repair'));
|
||||
await rejection;
|
||||
assert.ok(seen);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
|
||||
'preserved',
|
||||
);
|
||||
assert.deepEqual((await fs.readdir(root)).sort(), [
|
||||
'bin',
|
||||
'source',
|
||||
'started',
|
||||
]);
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('real Node resolution failures distinguish broken dependency entries and internal imports from backend source', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-missing-entry-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const directory = path.join(root, 'node_modules/broken-entry');
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(directory, 'package.json'),
|
||||
JSON.stringify({ main: 'missing.js' }),
|
||||
);
|
||||
const load = require('node:module').createRequire(
|
||||
path.join(root, 'package.json'),
|
||||
);
|
||||
assert.throws(
|
||||
() => load('broken-entry'),
|
||||
(error) => dependencyFailure(error),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(directory, 'missing.js'),
|
||||
"require('./missing-internal-file')",
|
||||
);
|
||||
assert.throws(
|
||||
() => load('broken-entry'),
|
||||
(error) => dependencyFailure(error),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'api.js'),
|
||||
"require('./missing-api-route')",
|
||||
);
|
||||
assert.throws(
|
||||
() => load('./api.js'),
|
||||
(error) => !dependencyFailure(error),
|
||||
);
|
||||
});
|
||||
|
||||
test('ARMv6 repair isolates configuration, uses frozen install, one compiler job and interpreted Node', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-armv6-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source'),
|
||||
bin = path.join(root, 'bin');
|
||||
await fs.mkdir(source);
|
||||
await fs.mkdir(bin);
|
||||
await fs.writeFile(
|
||||
path.join(source, 'package.json'),
|
||||
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
|
||||
);
|
||||
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
|
||||
await fs.writeFile(
|
||||
path.join(bin, 'pnpm'),
|
||||
`#!${process.execPath}
|
||||
const fs=require('node:fs');
|
||||
if(process.argv[2]==='--version') {console.error('harmless runtime warning'); console.log('8.3.1');}
|
||||
else if(process.argv[2]==='install') {
|
||||
if(!process.argv.includes('--frozen-lockfile')||!process.argv.includes('--prod')) throw Error('not frozen production install');
|
||||
fs.mkdirSync('node_modules');fs.writeFileSync('node_modules/flags.json',JSON.stringify({
|
||||
args:process.argv,options:process.env.NODE_OPTIONS,source:process.env.npm_config_build_from_source,
|
||||
processConfig:process.env.npm_config_force_process_config,jobs:process.env.npm_config_jobs,make:process.env.MAKEFLAGS
|
||||
}));
|
||||
} else if(process.argv.slice(2).join(' ')!=='rebuild sqlite3 @whyour/sqlite3') throw Error('unexpected command');
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await repairDependencies(source, {
|
||||
target: { arch: 'armv6' },
|
||||
env: {
|
||||
PATH: bin,
|
||||
NODE_OPTIONS: '--require untrusted',
|
||||
npm_config_nodedir: '/wrong-node',
|
||||
},
|
||||
});
|
||||
const flags = JSON.parse(
|
||||
await fs.readFile(path.join(source, 'node_modules/flags.json')),
|
||||
);
|
||||
assert.match(flags.options, /--jitless/);
|
||||
assert.match(flags.options, /max-old-space-size=256/);
|
||||
assert.equal(flags.source, 'true');
|
||||
assert.equal(flags.processConfig, 'true');
|
||||
assert.equal(flags.jobs, '1');
|
||||
assert.equal(flags.make, '-j1');
|
||||
assert.deepEqual((await fs.readdir(root)).sort(), ['bin', 'source']);
|
||||
});
|
||||
|
||||
test('repair classification excludes application, platform, config, database, OOM and timeouts', () => {
|
||||
for (const error of [
|
||||
{ code: 'MODULE_NOT_FOUND', message: "Cannot find module './api/missing'" },
|
||||
{ message: 'Bundle platform mismatch' },
|
||||
{ message: 'SQLITE_CANTOPEN' },
|
||||
{ message: 'Upgrade prestart timed out' },
|
||||
{
|
||||
code: 'ERR_DLOPEN_FAILED',
|
||||
message: 'node_modules/sqlite3/node.node',
|
||||
signal: 'SIGKILL',
|
||||
},
|
||||
{ message: 'JavaScript heap out of memory' },
|
||||
])
|
||||
assert.equal(dependencyFailure(error), false, JSON.stringify(error));
|
||||
assert.equal(
|
||||
dependencyFailure({
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
message: "Cannot find module 'decimal.js'",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
dependencyFailure({
|
||||
code: 'ERR_DLOPEN_FAILED',
|
||||
message: 'dlopen /tmp/node_modules/sqlite3/node.node: invalid ELF header',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
dependencyFailure({
|
||||
message:
|
||||
'Could not locate the bindings file /tmp/node_modules/sqlite3/bindings',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(probeLimits({ arch: 'armv6' }), {
|
||||
heapMb: 128,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
});
|
||||
|
||||
test('prestart deadline kills its process group and removes temporary data', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-prestart-timeout-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source'),
|
||||
statics = path.join(root, 'static');
|
||||
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
||||
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
|
||||
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
|
||||
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
|
||||
await fs.writeFile(
|
||||
path.join(statics, 'build-info.json'),
|
||||
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
|
||||
);
|
||||
const survivor = path.join(root, 'survivor');
|
||||
await fs.writeFile(
|
||||
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
|
||||
`
|
||||
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
|
||||
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
|
||||
survivor,
|
||||
)},'escaped'),2200)`,
|
||||
)}],{stdio:'ignore'});
|
||||
setInterval(()=>{},1000);
|
||||
`,
|
||||
);
|
||||
await assert.rejects(
|
||||
prestartBackend(
|
||||
{ source, static: statics },
|
||||
{
|
||||
target: { arch: 'armv6' },
|
||||
limits: { heapMb: 128, timeoutMs: 1000 },
|
||||
},
|
||||
),
|
||||
/timed out/,
|
||||
);
|
||||
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('repair refuses an unsupported package manager before invoking a network command', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-lock-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source');
|
||||
await fs.mkdir(source);
|
||||
await fs.writeFile(path.join(source, 'package.json'), '{}');
|
||||
await assert.rejects(
|
||||
repairDependencies(source, {
|
||||
target: { arch: 'armv6' },
|
||||
env: { PATH: '' },
|
||||
}),
|
||||
/Unsupported frozen-lockfile/,
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(root), ['source']);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
|
||||
function layout(width, legacy = false) {
|
||||
const values = [],
|
||||
listeners = new Set(),
|
||||
attributes = new Map(),
|
||||
effects = [];
|
||||
let cursor = 0,
|
||||
mounted = false;
|
||||
const media = { matches: width < 768 };
|
||||
const add = (fn) => listeners.add(fn),
|
||||
remove = (fn) => listeners.delete(fn);
|
||||
Object.assign(
|
||||
media,
|
||||
legacy
|
||||
? { addListener: add, removeListener: remove }
|
||||
: {
|
||||
addEventListener: (_, fn) => add(fn),
|
||||
removeEventListener: (_, fn) => remove(fn),
|
||||
},
|
||||
);
|
||||
const module = { exports: {} };
|
||||
const source = ts.transpileModule(
|
||||
fs.readFileSync(path.join(__dirname, '../../src/utils/hooks.ts'), 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
new Function('require', 'module', 'exports', 'window', 'document', source)(
|
||||
(name) => {
|
||||
assert.equal(name, 'react');
|
||||
return {
|
||||
useState: (initial) => {
|
||||
const index = cursor++;
|
||||
if (!(index in values))
|
||||
values[index] = typeof initial === 'function' ? initial() : initial;
|
||||
return [
|
||||
values[index],
|
||||
(value) => {
|
||||
values[index] = value;
|
||||
},
|
||||
];
|
||||
},
|
||||
useEffect: (fn) => {
|
||||
if (!mounted) effects.push(fn);
|
||||
},
|
||||
};
|
||||
},
|
||||
module,
|
||||
module.exports,
|
||||
{ matchMedia: () => media, navigator: { userAgent: 'Desktop browser' } },
|
||||
{ body: { setAttribute: (key, value) => attributes.set(key, value) } },
|
||||
);
|
||||
const render = () => {
|
||||
cursor = 0;
|
||||
return module.exports.useCtx();
|
||||
};
|
||||
const initial = render();
|
||||
const cleanup = effects.map((fn) => fn());
|
||||
mounted = true;
|
||||
return {
|
||||
initial,
|
||||
attributes,
|
||||
listeners,
|
||||
resize: (width) => {
|
||||
media.matches = width < 768;
|
||||
listeners.forEach((fn) => fn());
|
||||
return render();
|
||||
},
|
||||
unmount: () => cleanup.forEach((fn) => fn?.()),
|
||||
};
|
||||
}
|
||||
|
||||
test('narrow desktop windows retain the top navigation and user controls, and follow viewport changes', () => {
|
||||
const view = layout(499);
|
||||
assert.equal(view.initial.isPhone, true);
|
||||
assert.equal(view.initial.headerStyle.marginTop, 0);
|
||||
assert.equal(view.attributes.get('data-mode'), 'phone');
|
||||
assert.equal(view.listeners.size, 1);
|
||||
const desktop = view.resize(1024);
|
||||
assert.equal(desktop.isPhone, false);
|
||||
assert.equal(desktop.headerStyle.marginTop, -48);
|
||||
assert.equal(view.attributes.get('data-mode'), 'desktop');
|
||||
const narrow = view.resize(767.5);
|
||||
assert.equal(narrow.isPhone, true);
|
||||
assert.equal(narrow.headerStyle.marginTop, 0);
|
||||
assert.equal(view.attributes.get('data-mode'), 'phone');
|
||||
assert.equal(view.resize(768).isPhone, false);
|
||||
view.unmount();
|
||||
assert.equal(view.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('older media query listeners are removed when the layout unmounts', () => {
|
||||
const view = layout(1024, true);
|
||||
assert.equal(view.resize(600).isPhone, true);
|
||||
assert.equal(view.attributes.get('data-mode'), 'phone');
|
||||
view.unmount();
|
||||
assert.equal(view.listeners.size, 0);
|
||||
});
|
||||
@@ -335,11 +335,15 @@ test('the persistent yellow sidebar entry shows live progress and remains access
|
||||
assert.match(p.nodes('button')[0].props['aria-label'], /62%/);
|
||||
p.nodes('button')[0].props.onClick();
|
||||
p.render({ collapsed: true });
|
||||
assert.match(p.nodes('button')[0].props.className, /collapsed/);
|
||||
assert.equal(p.nodes('button').length, 0);
|
||||
assert.equal(p.nodes(p.Modal)[0].props.open, true);
|
||||
f.store.setJob({ status: 'ready', phase: 'ready' });
|
||||
f.store.setJob({ status: 'ready', phase: 'completed' });
|
||||
p.render();
|
||||
assert.equal(p.nodes('button')[0].props['aria-label'], '更新已就绪');
|
||||
assert.equal(p.nodes('button').length, 0);
|
||||
p.nodes(p.Modal)[0].props.onCancel();
|
||||
p.render({ collapsed: true });
|
||||
assert.match(p.nodes('button')[0].props.className, /collapsed/);
|
||||
assert.equal(p.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
|
||||
});
|
||||
|
||||
test('completed update refreshes once and reminds once after reconnecting the refreshed page', async () => {
|
||||
@@ -433,3 +437,32 @@ test('update modal uses plain aligned content and explains restart state', () =>
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('sidebar and dialog use the same authoritative status even when old phase fields disagree', () => {
|
||||
const f = fixture(),
|
||||
notice = f.component('src/components/UpgradeNotice.tsx'),
|
||||
panel = f.component('src/pages/setting/checkUpdate.tsx');
|
||||
f.store.setJob({ status: 'ready', phase: 'completed' });
|
||||
notice.render();
|
||||
panel.render();
|
||||
assert.equal(
|
||||
notice.nodes('button')[0].props['aria-label'],
|
||||
f.hooks.upgradeStatusLabel(f.store.getSnapshot().job),
|
||||
);
|
||||
assert.ok(
|
||||
panel
|
||||
.nodes('Text')
|
||||
.some((node) => node.props.children.includes?.('下载完成,等待重启')),
|
||||
);
|
||||
f.store.setJob({ status: 'ready', phase: 'downloading', percent: 62 });
|
||||
notice.render();
|
||||
assert.equal(notice.nodes('button')[0].props['aria-label'], '下载完成,等待重启');
|
||||
f.store.setJob({ status: 'completed', phase: 'ready' });
|
||||
assert.equal(notice.render(), null);
|
||||
panel.render();
|
||||
assert.ok(
|
||||
panel
|
||||
.nodes('Text')
|
||||
.some((node) => node.props.children.includes?.('升级成功')),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { writeManifest, sourceCommit } = require('./upgrade-fixture.cjs');
|
||||
|
||||
async function manualUpgradeFixture(t, wrongVersion = false) {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-job-')),
|
||||
);
|
||||
const tmp = path.join(root, '.tmp');
|
||||
const workspace = path.join(tmp, 'upgrade-manual');
|
||||
const selected = {
|
||||
source: path.join(workspace, 'qinglong-develop'),
|
||||
static: path.join(workspace, 'qinglong-static-develop'),
|
||||
};
|
||||
for (const dir of [
|
||||
path.join(root, 'static/build'),
|
||||
path.join(root, 'shell'),
|
||||
path.join(root, 'data/config'),
|
||||
path.join(root, 'node_modules'),
|
||||
path.join(selected.source, 'sample'),
|
||||
path.join(selected.source, 'node_modules'),
|
||||
path.join(selected.static, 'build'),
|
||||
])
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'static/build/app.js'), 'old backend');
|
||||
await fs.writeFile(path.join(root, '.env'), 'preserve configuration');
|
||||
await fs.writeFile(path.join(root, 'data/keep'), 'preserve data');
|
||||
await fs.writeFile(path.join(root, 'node_modules/old'), 'old dependency');
|
||||
await fs.writeFile(
|
||||
path.join(root, 'shell/share.sh'),
|
||||
'import_config(){ :; }\ndelete_pm2(){ :; }\nreload_pm2(){ :; }\n',
|
||||
);
|
||||
await fs.writeFile(path.join(root, 'shell/env.sh'), '');
|
||||
await fs.writeFile(
|
||||
path.join(selected.source, 'sample/config.sample.sh'),
|
||||
'# sample',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(selected.source, 'package.json'),
|
||||
'{"version":"2.24.0"}',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(selected.source, 'version.yaml'),
|
||||
'version: 2.24.0\n',
|
||||
);
|
||||
await fs.writeFile(path.join(selected.static, 'build/app.js'), 'new backend');
|
||||
writeManifest(selected.source, selected.static);
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(selected.static, 'build-info.json')),
|
||||
);
|
||||
const expected = {
|
||||
version: '2.24.0',
|
||||
sourceCommit,
|
||||
backendSha256: manifest.files['build/app.js'],
|
||||
stageDirectory: 'upgrade-manual',
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({
|
||||
...selected,
|
||||
root,
|
||||
sourceCommit,
|
||||
dependenciesChanged: true,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tmp, 'upgrade-ready-develop.json'),
|
||||
'{"directory":"upgrade-manual"}',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tmp, 'panel-upgrade.json'),
|
||||
JSON.stringify({
|
||||
id: 'offline-prepared',
|
||||
action: 'download',
|
||||
status: 'ready',
|
||||
phase: 'ready',
|
||||
...expected,
|
||||
}),
|
||||
);
|
||||
const server = http.createServer(async (_request, response) => {
|
||||
const content = await fs
|
||||
.readFile(path.join(root, 'static/build/app.js'), 'utf8')
|
||||
.catch(() => 'stopped');
|
||||
const updated = content === 'new backend';
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
status: 'ok',
|
||||
services: { http: true, grpc: true },
|
||||
build: {
|
||||
version: updated ? '2.24.0' : '2.23.0',
|
||||
sourceCommit:
|
||||
updated && !wrongVersion ? sourceCommit : 'b'.repeat(40),
|
||||
backendSha256: createHash('sha256').update(content).digest('hex'),
|
||||
instanceId: updated ? 'new-instance' : 'old-instance',
|
||||
parentPid: updated ? 222 : 111,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
QL_DIR: root,
|
||||
QL_BRANCH: 'develop',
|
||||
dir_tmp: tmp,
|
||||
dir_static: path.join(root, 'static'),
|
||||
dir_data: path.join(root, 'data'),
|
||||
dir_config: path.join(root, 'data/config'),
|
||||
QlPort: String(server.address().port),
|
||||
QlBaseUrl: '/',
|
||||
};
|
||||
return {
|
||||
root,
|
||||
tmp,
|
||||
selected,
|
||||
env,
|
||||
expected,
|
||||
state: async () =>
|
||||
JSON.parse(await fs.readFile(path.join(tmp, 'panel-upgrade.json'))),
|
||||
};
|
||||
}
|
||||
module.exports = { manualUpgradeFixture };
|
||||
Reference in New Issue
Block a user