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:
@@ -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()),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user