Support digest-aware update center rollbacks (#318)

This commit is contained in:
Cita
2026-03-29 22:16:39 +08:00
committed by GitHub
parent b9ae85e98b
commit d47c3c4a29
16 changed files with 1323 additions and 124 deletions
+91 -4
View File
@@ -10,11 +10,13 @@ const {
fetchLatestDockerHubTagMock,
getUpdateCenterHelperStatusMock,
streamUpdateCenterDeployMock,
streamUpdateCenterRollbackMock,
} = vi.hoisted(() => ({
fetchLatestStableGitHubReleaseMock: vi.fn(),
fetchLatestDockerHubTagMock: vi.fn(),
getUpdateCenterHelperStatusMock: vi.fn(),
streamUpdateCenterDeployMock: vi.fn(),
streamUpdateCenterRollbackMock: vi.fn(),
}));
vi.mock('../../services/updateCenterVersionService.js', async () => {
@@ -29,6 +31,7 @@ vi.mock('../../services/updateCenterVersionService.js', async () => {
vi.mock('../../services/updateCenterHelperClient.js', () => ({
getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args),
streamUpdateCenterDeploy: (...args: unknown[]) => streamUpdateCenterDeployMock(...args),
streamUpdateCenterRollback: (...args: unknown[]) => streamUpdateCenterRollbackMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
@@ -89,6 +92,7 @@ describe('update center routes', () => {
fetchLatestDockerHubTagMock.mockReset();
getUpdateCenterHelperStatusMock.mockReset();
streamUpdateCenterDeployMock.mockReset();
streamUpdateCenterRollbackMock.mockReset();
resetBackgroundTasks?.();
await db.delete(schema.events).run();
@@ -112,8 +116,12 @@ describe('update center routes', () => {
});
fetchLatestDockerHubTagMock.mockResolvedValue({
source: 'docker-hub-tag',
rawVersion: '1.3.1',
normalizedVersion: '1.3.1',
rawVersion: 'latest',
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
displayVersion: 'latest @ sha256:efb2ee655386',
publishedAt: '2026-03-29T11:54:35.591877Z',
url: null,
});
getUpdateCenterHelperStatusMock.mockResolvedValue({
@@ -122,8 +130,20 @@ describe('update center routes', () => {
namespace: 'ai',
revision: '12',
imageRepository: '1467078763/metapi',
imageTag: '1.2.3',
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
healthy: true,
history: [
{
revision: '11',
updatedAt: '2026-03-28T12:00:00Z',
status: 'superseded',
description: 'Rollback to stable digest',
imageRepository: '1467078763/metapi',
imageTag: 'main',
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
],
});
const saveResponse = await app.inject({
@@ -164,12 +184,21 @@ describe('update center routes', () => {
normalizedVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: '1.3.1',
normalizedVersion: 'latest',
displayVersion: 'latest @ sha256:efb2ee655386',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
},
helper: {
ok: true,
healthy: true,
releaseName: 'metapi',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
history: [
{
revision: '11',
imageTag: 'main',
},
],
},
});
});
@@ -276,6 +305,7 @@ describe('update center routes', () => {
success: true,
targetSource: 'github-release',
targetTag: '1.3.0',
targetDigest: null,
previousRevision: '12',
finalRevision: '13',
rolledBack: false,
@@ -347,6 +377,62 @@ describe('update center routes', () => {
});
});
it('forwards digest-aware deploy requests to the helper client', async () => {
await saveValidConfig();
streamUpdateCenterDeployMock.mockResolvedValue({
success: true,
targetSource: 'docker-hub-tag',
targetTag: 'latest',
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
previousRevision: '13',
finalRevision: '14',
rolledBack: false,
logLines: ['Running helm upgrade'],
});
const deployResponse = await app.inject({
method: 'POST',
url: '/api/update-center/deploy',
payload: {
source: 'docker-hub-tag',
targetTag: 'latest',
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
},
});
expect(deployResponse.statusCode).toBe(202);
expect(streamUpdateCenterDeployMock).toHaveBeenCalledWith(expect.objectContaining({
source: 'docker-hub-tag',
targetTag: 'latest',
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
}), expect.any(Function));
});
it('starts rollback tasks for explicit revision requests', async () => {
await saveValidConfig();
streamUpdateCenterRollbackMock.mockResolvedValue({
success: true,
targetRevision: '11',
finalRevision: '15',
logLines: ['Running helm rollback'],
});
const rollbackResponse = await app.inject({
method: 'POST',
url: '/api/update-center/rollback',
payload: {
targetRevision: '11',
},
});
expect(rollbackResponse.statusCode).toBe(202);
expect(streamUpdateCenterRollbackMock).toHaveBeenCalledWith(expect.objectContaining({
targetRevision: '11',
}), expect.any(Function));
});
it('streams deployment logs for known tasks and rejects unknown task ids', async () => {
await saveValidConfig();
@@ -364,6 +450,7 @@ describe('update center routes', () => {
success: true,
targetSource: 'docker-hub-tag',
targetTag: '1.3.1',
targetDigest: null,
previousRevision: '13',
finalRevision: '14',
rolledBack: false,
+80 -7
View File
@@ -25,11 +25,18 @@ import {
import {
getUpdateCenterHelperStatus,
streamUpdateCenterDeploy,
streamUpdateCenterRollback,
} from '../../services/updateCenterHelperClient.js';
type DeployBody = {
source?: UpdateCenterVersionSource;
targetVersion?: string;
targetTag?: string;
targetDigest?: string;
};
type RollbackBody = {
targetRevision?: string;
};
const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
@@ -44,14 +51,18 @@ function getUpdateCenterHelperToken(): string {
).trim();
}
function assertDeployableConfig(config: UpdateCenterConfig) {
function assertHelperConfig(config: UpdateCenterConfig) {
if (!config.enabled) throw new Error('update center is disabled');
if (!config.helperBaseUrl) throw new Error('helperBaseUrl is required');
if (!config.namespace) throw new Error('namespace is required');
if (!config.releaseName) throw new Error('releaseName is required');
if (!getUpdateCenterHelperToken()) throw new Error('DEPLOY_HELPER_TOKEN is required');
}
function assertDeployableConfig(config: UpdateCenterConfig) {
assertHelperConfig(config);
if (!config.chartRef) throw new Error('chartRef is required');
if (!config.imageRepository) throw new Error('imageRepository is required');
if (!getUpdateCenterHelperToken()) throw new Error('DEPLOY_HELPER_TOKEN is required');
}
function getDeployTasks() {
@@ -163,11 +174,12 @@ export async function updateCenterRoutes(app: FastifyInstance) {
: request.body?.source === 'github-release'
? 'github-release'
: config.defaultDeploySource;
const targetVersion = String(request.body?.targetVersion || '').trim();
if (!targetVersion) {
const targetTag = String(request.body?.targetTag || request.body?.targetVersion || '').trim();
const targetDigest = String(request.body?.targetDigest || '').trim() || null;
if (!targetTag) {
return reply.code(400).send({
success: false,
message: 'targetVersion is required',
message: 'targetTag is required',
});
}
@@ -182,7 +194,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
},
async () => {
await Promise.resolve();
appendBackgroundTaskLog(taskId, `Resolving target version: ${targetVersion}`);
appendBackgroundTaskLog(taskId, `Resolving target image: ${targetTag}${targetDigest ? ` @ ${targetDigest}` : ''}`);
appendBackgroundTaskLog(taskId, `Contacting deploy helper: ${config.helperBaseUrl}`);
const result = await streamUpdateCenterDeploy(
@@ -190,7 +202,8 @@ export async function updateCenterRoutes(app: FastifyInstance) {
config,
helperToken: getUpdateCenterHelperToken(),
source,
targetVersion,
targetTag,
targetDigest,
},
(message) => {
appendBackgroundTaskLog(taskId, message);
@@ -213,6 +226,66 @@ export async function updateCenterRoutes(app: FastifyInstance) {
});
});
app.post<{ Body: RollbackBody }>('/api/update-center/rollback', async (request, reply) => {
const config = await loadUpdateCenterConfig();
try {
assertHelperConfig(config);
} catch (error) {
return reply.code(400).send({
success: false,
message: summarizeHelperError(error),
});
}
const targetRevision = String(request.body?.targetRevision || '').trim();
if (!targetRevision) {
return reply.code(400).send({
success: false,
message: 'targetRevision is required',
});
}
let taskId = '';
const { task, reused } = startBackgroundTask(
{
type: UPDATE_CENTER_DEPLOY_TASK_TYPE,
title: '更新中心回退',
dedupeKey: UPDATE_CENTER_DEPLOY_DEDUPE_KEY,
successTitle: '更新中心回退已完成',
failureTitle: '更新中心回退失败',
},
async () => {
await Promise.resolve();
appendBackgroundTaskLog(taskId, `Resolving rollback revision: ${targetRevision}`);
appendBackgroundTaskLog(taskId, `Contacting deploy helper: ${config.helperBaseUrl}`);
const result = await streamUpdateCenterRollback(
{
config,
helperToken: getUpdateCenterHelperToken(),
targetRevision,
},
(message) => {
appendBackgroundTaskLog(taskId, message);
},
);
if (!result.success) {
throw new Error('deploy helper reported a failed rollback');
}
appendBackgroundTaskLog(taskId, 'Rollback finished successfully');
return result;
},
);
taskId = task.id;
return reply.code(202).send({
success: true,
reused,
task,
});
});
app.get<{ Params: { id: string } }>('/api/update-center/tasks/:id/stream', async (request, reply) => {
const taskId = String(request.params.id || '').trim();
const task = getBackgroundTask(taskId);