Support digest-aware update center rollbacks (#318)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user