Skip to content

Commit 65a4cff

Browse files
committed
feat: stage online upgrades before verified restarts
Download updates asynchronously with persistent progress and validate matching source and build artifacts before an explicit restart. Keep upgrade workers alive across service restarts, verify the new runtime, and recover the browser with a one-time success notice. Publish immutable archive sizes and checksums for accurate download progress. Align the update dialog, hide unavailable actions during transfers, and restore the sidebar check action after a successful upgrade.
1 parent a1104ef commit 65a4cff

22 files changed

Lines changed: 2631 additions & 463 deletions

File tree

‎.github/workflows/build-docker-image.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ jobs:
9494
run: |
9595
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
9696
97+
- name: publish verified upgrade download sizes
98+
env:
99+
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
100+
GITHUB_BRANCH: ${{ github.ref_name }}
101+
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
102+
97103
static_gitlab:
98104
needs: build-static
99105
runs-on: ubuntu-latest

‎back/api/system.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,17 @@ export default (app: Router) => {
205205
},
206206
);
207207

208+
route.get(
209+
'/update-status',
210+
async (req: Request, res: Response, next: NextFunction) => {
211+
try {
212+
res.send(await Container.get(SystemService).getUpgradeStatus());
213+
} catch (e) {
214+
return next(e);
215+
}
216+
},
217+
);
218+
208219
route.put(
209220
'/reload',
210221
celebrate({

‎back/services/health.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { Service } from 'typedi';
22
import Logger from '../loaders/logger';
33
import cronClient from '../schedule/client';
44
import { HttpServerService } from './http';
5+
import { runtimeBuild } from '../shared/runtimeBuild';
56

67
interface HealthStatus {
8+
build: typeof runtimeBuild;
79
status: 'ok' | 'error';
810
services: {
911
http: boolean;
@@ -22,12 +24,11 @@ interface HealthStatus {
2224
export class HealthService {
2325
private startTime = Date.now();
2426

25-
constructor(
26-
private httpServerService: HttpServerService,
27-
) {}
27+
constructor(private httpServerService: HttpServerService) {}
2828

2929
async check(): Promise<HealthStatus> {
3030
const status: HealthStatus = {
31+
build: runtimeBuild,
3132
status: 'ok',
3233
services: {
3334
http: true,

‎back/services/system.ts‎

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -344,45 +344,51 @@ export default class SystemService {
344344
}
345345

346346
public async updateSystem() {
347-
const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' });
348-
349-
cp.stdout.on('data', (data) => {
350-
this.sockService.sendMessage({
351-
type: 'updateSystemVersion',
352-
message: data.toString(),
353-
});
354-
});
355-
356-
cp.stderr.on('data', (data) => {
357-
this.sockService.sendMessage({
358-
type: 'updateSystemVersion',
359-
message: data.toString(),
360-
});
361-
});
347+
return this.startUpgradeJob('download');
348+
}
362349

363-
cp.on('error', (err) => {
364-
this.sockService.sendMessage({
365-
type: 'updateSystemVersion',
366-
message: JSON.stringify(err),
367-
status: 'failed',
368-
});
369-
});
350+
private upgradeJobs() {
351+
return require(path.join(
352+
config.rootPath,
353+
'cli/src/internal/maintenance/panelUpgrade.cjs',
354+
));
355+
}
370356

371-
cp.on('exit', (code) => {
372-
this.sockService.sendMessage({
373-
type: 'updateSystemVersion',
374-
message: '',
375-
status: code === 0 ? 'success' : 'failed',
376-
});
377-
});
357+
public async getUpgradeStatus() {
358+
return {
359+
code: 200,
360+
data: await this.upgradeJobs().getStatus(config.tmpPath),
361+
};
362+
}
378363

379-
return { code: 200 };
364+
private async startUpgradeJob(action: 'download' | 'apply') {
365+
try {
366+
const data = await this.upgradeJobs().startJob(
367+
{
368+
root: config.rootPath,
369+
tmp: config.tmpPath,
370+
env: {
371+
...process.env,
372+
dir_data: config.dataPath,
373+
dir_config: config.configPath,
374+
QlPort: String(config.port),
375+
QlBaseUrl: config.baseUrl,
376+
},
377+
},
378+
action,
379+
);
380+
return { code: 200, data };
381+
} catch (error: any) {
382+
this.logger.error('[upgrade] %s', error.message);
383+
return { code: 400, message: t('升级任务启动失败,请检查日志后重试') };
384+
}
380385
}
381386

382387
public async reloadSystem(target?: 'system' | 'data') {
383388
if (target && !['system', 'data'].includes(target)) {
384389
return { code: 400, message: t('参数错误') };
385390
}
391+
if (target === 'system') return this.startUpgradeJob('apply');
386392
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
387393
const cp = spawn(cmd, {
388394
shell: '/bin/bash',

‎back/shared/i18n.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const messages: Record<string, Record<string, string>> = {
22
zh: {},
33
en: {
4+
'升级任务启动失败,请检查日志后重试':
5+
'Failed to start the update. Check the logs and try again.',
46
'[schedule][停止已运行任务] 任务ID: %s, PID: %s':
57
'[schedule][Stopping running task] Task ID: %s, PID: %s',
68

‎back/shared/runtimeBuild.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { readFileSync } from 'fs';
2+
import path from 'path';
3+
import { createHash, randomUUID } from 'crypto';
4+
5+
function read(filename: string): Buffer | undefined {
6+
try {
7+
return readFileSync(filename);
8+
} catch {
9+
return undefined;
10+
}
11+
}
12+
13+
function manifest(): { sourceCommit?: string } {
14+
try {
15+
const value = JSON.parse(
16+
read(path.resolve(__dirname, '../../build-info.json'))?.toString() ||
17+
'{}',
18+
);
19+
return value && typeof value === 'object' ? value : {};
20+
} catch {
21+
return {};
22+
}
23+
}
24+
25+
// Capture before serving requests. Reading the installed files on each request
26+
// would let an old process claim the identity of a newly copied build.
27+
const backend = read(path.resolve(__dirname, '../app.js'));
28+
const root = process.env.QL_DIR || path.resolve(__dirname, '../../..');
29+
const version = read(path.join(root, 'version.yaml'))
30+
?.toString()
31+
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
32+
33+
export const runtimeBuild = Object.freeze({
34+
instanceId: randomUUID(),
35+
parentPid: process.ppid,
36+
version,
37+
sourceCommit: manifest().sourceCommit,
38+
backendSha256: backend
39+
? createHash('sha256').update(backend).digest('hex')
40+
: undefined,
41+
});

0 commit comments

Comments
 (0)