feat(api): add online console update with pre-install backup

- Add console update planning, download validation and staged installation.

- Create a component backup before applying online updates.

- Expose controlled update routes from the production control plane.
This commit is contained in:
chick
2026-09-07 00:44:58 +08:00
parent 961eb928c4
commit 7168e9dba2
5 changed files with 1427 additions and 0 deletions
@@ -0,0 +1,123 @@
import Fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import {
UpdateError,
type ConsoleUpdateService,
type UpdateStatus,
} from '../../application/system/console-update-service.js';
import { registerUpdateRoutes } from './update-routes.js';
let app: ReturnType<typeof Fastify> | undefined;
function baseStatus(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return {
phase: 'update_available',
message: '发现新版本 1b2c3d4,可以下载更新包。',
error: '',
progressPercent: 100,
checkedAt: '2026-09-06T00:00:00.000Z',
deploymentMode: 'git',
installSupported: true,
restartSupported: true,
release: {
status: 'update_available',
remote: 'https://example.test/repo.git',
branch: 'main',
currentCommit: 'a'.repeat(40),
latestCommit: 'b'.repeat(40),
currentVersion: '0.1.0',
latestVersion: '',
ahead: 0,
changedFiles: 0,
changedLines: 0,
summary: '',
},
...overrides,
};
}
function mount(partial: Partial<Record<keyof ConsoleUpdateService, () => Promise<unknown>>>) {
const instance = Fastify();
registerUpdateRoutes(instance, {
updates: partial as unknown as ConsoleUpdateService,
});
app = instance;
return instance;
}
afterEach(async () => {
await app?.close();
app = undefined;
});
describe('update routes', () => {
it('exposes the four update commands and the restart', async () => {
const calls: string[] = [];
const updates = {
status: async () => {
calls.push('status');
return baseStatus();
},
check: async () => {
calls.push('check');
return baseStatus();
},
download: async () => {
calls.push('download');
return baseStatus({ phase: 'ready' });
},
install: async () => {
calls.push('install');
return baseStatus({ phase: 'install_queued' });
},
restart: async () => {
calls.push('restart');
return baseStatus({ phase: 'restarting' });
},
};
const instance = Fastify();
registerUpdateRoutes(instance, { updates: updates as unknown as ConsoleUpdateService });
app = instance;
const routes = [
['GET', '/api/v1/system/update'],
['POST', '/api/v1/system/update/check'],
['POST', '/api/v1/system/update/download'],
['POST', '/api/v1/system/update/install'],
['POST', '/api/v1/system/restart'],
] as const;
for (const [method, url] of routes) {
const response = await instance.inject({ method, url });
expect(response.statusCode).toBe(200);
expect(response.json().phase).toBeTruthy();
}
expect(calls).toEqual(['status', 'check', 'download', 'install', 'restart']);
});
it('maps an update error onto a problem document', async () => {
const instance = mount({
restart: async () => {
throw new UpdateError('NOT_SUPPORTED', '当前部署方式不支持在线重启。', 501);
},
});
const response = await instance.inject({ method: 'POST', url: '/api/v1/system/restart' });
expect(response.statusCode).toBe(501);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.json()).toMatchObject({
code: 'UPDATE_NOT_SUPPORTED',
title: 'Not Implemented',
detail: '当前部署方式不支持在线重启。',
});
});
it('keeps an unexpected failure on the global error handler', async () => {
const instance = mount({
status: async () => {
throw new Error('boom');
},
});
const response = await instance.inject({ method: 'GET', url: '/api/v1/system/update' });
expect(response.statusCode).toBe(500);
});
});
@@ -0,0 +1,85 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
UpdateError,
type ConsoleUpdateService,
} from '../../application/system/console-update-service.js';
export interface UpdateRoutesOptions {
readonly updates: ConsoleUpdateService;
}
function problem(
reply: FastifyReply,
request: FastifyRequest,
status: number,
code: string,
title: string,
detail: string,
) {
return reply.code(status).type('application/problem+json').send({
type: 'about:blank',
title,
status,
code,
detail,
requestId: request.id,
});
}
/**
* The console's own update surface, mirroring the Hub: check, download, install, restart.
* Every action answers with the full status so the client never has to guess the next phase.
*/
export function registerUpdateRoutes(app: FastifyInstance, options: UpdateRoutesOptions): void {
const wrap =
<T>(action: () => Promise<T>) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return await action();
} catch (error) {
if (error instanceof UpdateError) {
const title =
error.statusCode === 501
? 'Not Implemented'
: error.statusCode === 502
? 'Bad Gateway'
: error.statusCode === 500
? 'Internal Server Error'
: error.statusCode === 409
? 'Conflict'
: 'Bad Request';
return problem(
reply,
request,
error.statusCode,
`UPDATE_${error.code}`,
title,
error.message,
);
}
throw error;
}
};
app.get(
'/api/v1/system/update',
wrap(async () => options.updates.status()),
);
app.post(
'/api/v1/system/update/check',
wrap(() => options.updates.check()),
);
app.post(
'/api/v1/system/update/download',
wrap(() => options.updates.download()),
);
app.post(
'/api/v1/system/update/install',
wrap(() => options.updates.install()),
);
app.post(
'/api/v1/system/restart',
wrap(() => options.updates.restart()),
);
}