forked from boos/BoosAPI
Support digest-aware update center rollbacks (#318)
This commit is contained in:
@@ -113,9 +113,10 @@ docker compose up -d
|
||||
|
||||
- 一个可用的 K3s / Kubernetes 集群
|
||||
- 用 Helm 部署的 Metapi release
|
||||
- 你的 chart 支持下面两个 values:
|
||||
- 你的 chart 至少支持下面三个 values:
|
||||
- `image.repository`
|
||||
- `image.tag`
|
||||
- `image.digest`
|
||||
- 目标 Deployment 带有 `app.kubernetes.io/instance=<releaseName>` 标签
|
||||
- 主 Metapi 服务可以访问:
|
||||
- GitHub API
|
||||
@@ -129,6 +130,7 @@ docker compose up -d
|
||||
Deploy Helper 是一个跑在集群里的小服务。它不负责对外提供 Metapi 功能,只负责接受主 Metapi 发来的部署请求,然后在集群里执行:
|
||||
|
||||
- `helm history`
|
||||
- `helm get values`
|
||||
- `helm upgrade`
|
||||
- `kubectl rollout status`
|
||||
- 必要时 `helm rollback`
|
||||
@@ -223,9 +225,11 @@ helper 端使用:
|
||||
3. 确认页面状态都正常:
|
||||
- 当前运行版本正常显示
|
||||
- 版本来源发现了可部署版本
|
||||
- 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest
|
||||
- Deploy Helper 显示健康
|
||||
4. 再点部署按钮
|
||||
5. 在页面下方看部署日志
|
||||
6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像
|
||||
|
||||
如果实时日志流断开,页面会自动回退到最近任务快照。
|
||||
|
||||
@@ -258,6 +262,8 @@ About 页里的“更新提醒”更像一个轻量入口:
|
||||
- K3s / Kubernetes
|
||||
- Helm release
|
||||
- Deploy Helper
|
||||
|
||||
另外,如果你想用“按 digest 精确部署/回退”这条链路,还要确认你的 chart 没有忽略 `image.digest` 这个值;否则页面虽然能显示 digest,真正部署时还是只会落到 tag 语义。
|
||||
- 对齐的 token
|
||||
|
||||
缺其中任何一项,都只能看,不能真正部署。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,7 +12,11 @@ vi.mock('undici', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { getUpdateCenterHelperStatus } from './updateCenterHelperClient.js';
|
||||
import {
|
||||
getUpdateCenterHelperStatus,
|
||||
streamUpdateCenterDeploy,
|
||||
streamUpdateCenterRollback,
|
||||
} from './updateCenterHelperClient.js';
|
||||
|
||||
describe('update center helper client', () => {
|
||||
beforeEach(() => {
|
||||
@@ -36,4 +40,98 @@ describe('update center helper client', () => {
|
||||
defaultDeploySource: 'github-release',
|
||||
}, 'helper-token')).rejects.toThrow('deploy helper status timeout');
|
||||
});
|
||||
|
||||
it('posts digest-aware deploy payloads to the helper and parses the streamed result', async () => {
|
||||
fetchMock.mockResolvedValue(new Response([
|
||||
'event: log',
|
||||
'data: {"message":"Running helm upgrade"}',
|
||||
'',
|
||||
'event: result',
|
||||
'data: {"success":true,"targetSource":"docker-hub-tag","targetTag":"latest","targetDigest":"sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35","previousRevision":"17","finalRevision":"18","rolledBack":false,"logLines":["Running helm upgrade"]}',
|
||||
'',
|
||||
].join('\n'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}));
|
||||
|
||||
const messages: string[] = [];
|
||||
const result = await streamUpdateCenterDeploy({
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
helperToken: 'helper-token',
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
}, (message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('http://metapi-deploy-helper.ai.svc.cluster.local:9850/deploy');
|
||||
const deployRequest = fetchMock.mock.calls[0]?.[1] as { method?: string; body?: string };
|
||||
expect(deployRequest?.method).toBe('POST');
|
||||
expect(JSON.parse(String(deployRequest?.body || '{}'))).toMatchObject({
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
});
|
||||
expect(messages).toEqual(['Running helm upgrade']);
|
||||
expect(result).toMatchObject({
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
finalRevision: '18',
|
||||
});
|
||||
});
|
||||
|
||||
it('posts rollback requests to the helper and parses the streamed result', async () => {
|
||||
fetchMock.mockResolvedValue(new Response([
|
||||
'event: log',
|
||||
'data: {"message":"Running helm rollback"}',
|
||||
'',
|
||||
'event: result',
|
||||
'data: {"success":true,"targetRevision":"16","finalRevision":"20","logLines":["Running helm rollback"]}',
|
||||
'',
|
||||
].join('\n'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}));
|
||||
|
||||
const messages: string[] = [];
|
||||
const result = await streamUpdateCenterRollback({
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
helperToken: 'helper-token',
|
||||
targetRevision: '16',
|
||||
}, (message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('http://metapi-deploy-helper.ai.svc.cluster.local:9850/rollback');
|
||||
const rollbackRequest = fetchMock.mock.calls[0]?.[1] as { method?: string; body?: string };
|
||||
expect(rollbackRequest?.method).toBe('POST');
|
||||
expect(JSON.parse(String(rollbackRequest?.body || '{}'))).toMatchObject({
|
||||
targetRevision: '16',
|
||||
});
|
||||
expect(messages).toEqual(['Running helm rollback']);
|
||||
expect(result).toMatchObject({
|
||||
targetRevision: '16',
|
||||
finalRevision: '20',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,17 @@ export type UpdateCenterHelperStatus = {
|
||||
revision: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
imageDigest: string | null;
|
||||
healthy: boolean;
|
||||
history?: Array<{
|
||||
revision: string;
|
||||
updatedAt: string | null;
|
||||
status: string | null;
|
||||
description: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
imageDigest: string | null;
|
||||
}>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -20,12 +30,20 @@ export type UpdateCenterDeploySummary = {
|
||||
success: boolean;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
targetDigest: string | null;
|
||||
previousRevision: string | null;
|
||||
finalRevision: string | null;
|
||||
rolledBack: boolean;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
export type UpdateCenterRollbackSummary = {
|
||||
success: boolean;
|
||||
targetRevision: string;
|
||||
finalRevision: string | null;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
type HelperDeployLogEvent = {
|
||||
message: string;
|
||||
};
|
||||
@@ -118,7 +136,8 @@ export async function streamUpdateCenterDeploy(
|
||||
config: UpdateCenterConfig;
|
||||
helperToken: string;
|
||||
source: UpdateCenterVersionSource;
|
||||
targetVersion: string;
|
||||
targetTag: string;
|
||||
targetDigest?: string | null;
|
||||
},
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<UpdateCenterDeploySummary> {
|
||||
@@ -134,7 +153,8 @@ export async function streamUpdateCenterDeploy(
|
||||
chartRef: input.config.chartRef,
|
||||
imageRepository: input.config.imageRepository,
|
||||
source: input.source,
|
||||
targetVersion: input.targetVersion,
|
||||
targetTag: input.targetTag,
|
||||
targetDigest: input.targetDigest || null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -187,6 +207,76 @@ export async function streamUpdateCenterDeploy(
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
export async function streamUpdateCenterRollback(
|
||||
input: {
|
||||
config: UpdateCenterConfig;
|
||||
helperToken: string;
|
||||
targetRevision: string;
|
||||
},
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<UpdateCenterRollbackSummary> {
|
||||
const response = await fetch(`${input.config.helperBaseUrl.replace(/\/$/, '')}/rollback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...buildHelperHeaders(input.helperToken, 'text/event-stream'),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namespace: input.config.namespace,
|
||||
releaseName: input.config.releaseName,
|
||||
targetRevision: input.targetRevision,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`helper rollback failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('helper rollback response did not include a stream body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
let finalResult: UpdateCenterRollbackSummary | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = parseSseBuffer(buffer, (event, data) => {
|
||||
if (event === 'log' && data && typeof data === 'object') {
|
||||
const message = String((data as HelperDeployLogEvent).message || '').trim();
|
||||
if (message) onLog?.(message);
|
||||
return;
|
||||
}
|
||||
if (event === 'result') {
|
||||
finalResult = data as UpdateCenterRollbackSummary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
parseSseBuffer(`${buffer}\n\n`, (event, data) => {
|
||||
if (event === 'log' && data && typeof data === 'object') {
|
||||
const message = String((data as HelperDeployLogEvent).message || '').trim();
|
||||
if (message) onLog?.(message);
|
||||
return;
|
||||
}
|
||||
if (event === 'result') {
|
||||
finalResult = data as UpdateCenterRollbackSummary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error('helper rollback stream ended without a result event');
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
export const __updateCenterHelperClientTestUtils = {
|
||||
encodeSseEvent,
|
||||
};
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('update center version service', () => {
|
||||
});
|
||||
|
||||
describe('selectLatestDockerHubTag', () => {
|
||||
it('ignores non-semver tags and prereleases', () => {
|
||||
it('prefers release aliases ahead of semver tags', () => {
|
||||
const latest = selectLatestDockerHubTag([
|
||||
'latest',
|
||||
'main',
|
||||
@@ -125,8 +125,8 @@ describe('update center version service', () => {
|
||||
]);
|
||||
|
||||
expect(latest).toMatchObject({
|
||||
rawVersion: '1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
rawVersion: 'latest',
|
||||
normalizedVersion: 'latest',
|
||||
source: 'docker-hub-tag',
|
||||
});
|
||||
});
|
||||
@@ -200,13 +200,27 @@ describe('update center version service', () => {
|
||||
});
|
||||
|
||||
describe('fetchLatestDockerHubTag', () => {
|
||||
it('fetches tags and returns the latest semver tag only', async () => {
|
||||
it('prefers alias tags like latest and includes digest metadata', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
results: [
|
||||
{ name: 'latest' },
|
||||
{ name: 'v1.2.3-rc.1' },
|
||||
{ name: 'v1.2.3' },
|
||||
{ name: '1.10.0' },
|
||||
{
|
||||
name: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
tag_last_pushed: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
name: 'v1.2.3-rc.1',
|
||||
},
|
||||
{
|
||||
name: 'v1.2.3',
|
||||
digest: 'sha256:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
tag_last_pushed: '2026-03-28T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
name: '1.10.0',
|
||||
digest: 'sha256:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
tag_last_pushed: '2026-03-27T11:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
@@ -216,9 +230,37 @@ describe('update center version service', () => {
|
||||
const latest = await fetchLatestDockerHubTag();
|
||||
expect(latest).toMatchObject({
|
||||
source: 'docker-hub-tag',
|
||||
normalizedVersion: '1.10.0',
|
||||
rawVersion: 'latest',
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v2/repositories/1467078763/metapi/tags');
|
||||
});
|
||||
|
||||
it('falls back to the highest stable semver tag when no alias tags are present', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
results: [
|
||||
{ name: 'sha-b9ae85e' },
|
||||
{ name: 'v1.2.3' },
|
||||
{ name: '1.10.0', digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const latest = await fetchLatestDockerHubTag();
|
||||
expect(latest).toMatchObject({
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
tagName: '1.10.0',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: '1.10.0 @ sha256:aaaaaaaaaaaa',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ export type UpdateCenterVersionCandidate = {
|
||||
rawVersion: string;
|
||||
normalizedVersion: string;
|
||||
url: string | null;
|
||||
tagName?: string | null;
|
||||
digest?: string | null;
|
||||
displayVersion?: string | null;
|
||||
publishedAt?: string | null;
|
||||
};
|
||||
|
||||
export type GitHubReleaseRecord = {
|
||||
@@ -28,10 +32,18 @@ export type GitHubReleaseRecord = {
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export type DockerHubTagRecord = {
|
||||
name?: string | null;
|
||||
tag_last_pushed?: string | null;
|
||||
last_updated?: string | null;
|
||||
digest?: string | null;
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100';
|
||||
const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||
const PREFERRED_DOCKER_HUB_TAG_ALIASES = ['latest', 'main'] as const;
|
||||
|
||||
async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
@@ -106,28 +118,80 @@ export function selectLatestStableGitHubRelease(
|
||||
rawVersion: selected.release.tag_name || selected.semver.raw,
|
||||
normalizedVersion: selected.semver.normalized,
|
||||
url: selected.release.html_url || null,
|
||||
tagName: selected.release.tag_name || selected.semver.raw,
|
||||
displayVersion: selected.semver.normalized,
|
||||
publishedAt: selected.release.published_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectLatestDockerHubTag(tags: string[]): UpdateCenterVersionCandidate | null {
|
||||
let selected: StableSemVer | null = null;
|
||||
function normalizeDockerHubTagRecord(input: string | DockerHubTagRecord): DockerHubTagRecord {
|
||||
if (typeof input === 'string') {
|
||||
return {
|
||||
name: input,
|
||||
};
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const semver = parseStableSemVer(tag);
|
||||
function normalizeDockerDigest(input: string | null | undefined): string | null {
|
||||
const digest = String(input || '').trim();
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function getDockerHubTagPublishedAt(record: DockerHubTagRecord): string | null {
|
||||
const value = String(record.tag_last_pushed || record.last_updated || '').trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function toShortDigest(digest: string | null | undefined): string | null {
|
||||
if (!digest) return null;
|
||||
return digest.slice(0, 'sha256:'.length + 12);
|
||||
}
|
||||
|
||||
function buildDockerHubVersionCandidate(
|
||||
record: DockerHubTagRecord,
|
||||
normalizedVersion: string,
|
||||
): UpdateCenterVersionCandidate | null {
|
||||
const rawVersion = String(record.name || '').trim();
|
||||
if (!rawVersion) return null;
|
||||
const digest = normalizeDockerDigest(record.digest);
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion,
|
||||
normalizedVersion,
|
||||
url: null,
|
||||
tagName: rawVersion,
|
||||
digest,
|
||||
displayVersion: digest ? `${rawVersion} @ ${toShortDigest(digest)}` : rawVersion,
|
||||
publishedAt: getDockerHubTagPublishedAt(record),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectLatestDockerHubTag(tags: Array<string | DockerHubTagRecord>): UpdateCenterVersionCandidate | null {
|
||||
const records = tags
|
||||
.map((tag) => normalizeDockerHubTagRecord(tag))
|
||||
.filter((record) => String(record.name || '').trim());
|
||||
|
||||
for (const alias of PREFERRED_DOCKER_HUB_TAG_ALIASES) {
|
||||
const record = records.find((entry) => String(entry.name || '').trim() === alias);
|
||||
if (!record) continue;
|
||||
const candidate = buildDockerHubVersionCandidate(record, alias);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
let selected: { record: DockerHubTagRecord; semver: StableSemVer } | null = null;
|
||||
|
||||
for (const record of records) {
|
||||
const semver = parseStableSemVer(record.name);
|
||||
if (!semver) continue;
|
||||
if (!selected || compareStableSemVer(semver, selected) > 0) {
|
||||
selected = semver;
|
||||
if (!selected || compareStableSemVer(semver, selected.semver) > 0) {
|
||||
selected = { record, semver };
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: selected.raw,
|
||||
normalizedVersion: selected.normalized,
|
||||
url: null,
|
||||
};
|
||||
return buildDockerHubVersionCandidate(selected.record, selected.semver.normalized);
|
||||
}
|
||||
|
||||
export function resolvePreferredDeploySource(input: {
|
||||
@@ -157,11 +221,8 @@ export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCand
|
||||
accept: 'application/json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
},
|
||||
}, 'Docker Hub tag lookup') as { results?: Array<{ name?: string | null }> };
|
||||
const tags = Array.isArray(payload?.results)
|
||||
? payload.results.map((item) => String(item?.name || '')).filter(Boolean)
|
||||
: [];
|
||||
return selectLatestDockerHubTag(tags);
|
||||
}, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] };
|
||||
return selectLatestDockerHubTag(Array.isArray(payload?.results) ? payload.results : []);
|
||||
}
|
||||
|
||||
export function getCurrentRuntimeVersion(): string {
|
||||
|
||||
@@ -49,8 +49,20 @@ describe('update helper app', () => {
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.3.1',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
healthy: true,
|
||||
history: [
|
||||
{
|
||||
revision: '16',
|
||||
updatedAt: '2026-03-28T12:00:00Z',
|
||||
status: 'superseded',
|
||||
description: 'Rollback to stable digest',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: 'main',
|
||||
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const app = await buildUpdateHelperApp({
|
||||
@@ -73,6 +85,7 @@ describe('update helper app', () => {
|
||||
healthy: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
});
|
||||
expect(getStatus).toHaveBeenCalledWith({
|
||||
namespace: 'ai',
|
||||
@@ -88,6 +101,7 @@ describe('update helper app', () => {
|
||||
success: true,
|
||||
targetSource: input.targetSource,
|
||||
targetTag: input.targetTag,
|
||||
targetDigest: input.targetDigest,
|
||||
previousRevision: '17',
|
||||
finalRevision: '18',
|
||||
rolledBack: false,
|
||||
@@ -115,7 +129,8 @@ describe('update helper app', () => {
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,6 +139,49 @@ describe('update helper app', () => {
|
||||
expect(response.body).toContain('event: log');
|
||||
expect(response.body).toContain('Running helm upgrade');
|
||||
expect(response.body).toContain('event: result');
|
||||
expect(response.body).toContain('"targetTag":"1.3.0"');
|
||||
expect(response.body).toContain('"targetTag":"latest"');
|
||||
expect(response.body).toContain('"targetDigest":"sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35"');
|
||||
});
|
||||
|
||||
it('streams rollback logs and final result as SSE when requested', async () => {
|
||||
const rollback = vi.fn().mockImplementation(async (input, onLog) => {
|
||||
onLog?.('Running helm rollback');
|
||||
onLog?.('Waiting for rollout');
|
||||
return {
|
||||
success: true,
|
||||
targetRevision: input.targetRevision,
|
||||
finalRevision: '20',
|
||||
logLines: ['Running helm rollback', 'Waiting for rollout'],
|
||||
};
|
||||
});
|
||||
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
rollback,
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/rollback',
|
||||
headers: {
|
||||
authorization: 'Bearer helper-token',
|
||||
accept: 'text/event-stream',
|
||||
},
|
||||
payload: {
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
targetRevision: '16',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('event: log');
|
||||
expect(response.body).toContain('Running helm rollback');
|
||||
expect(response.body).toContain('event: result');
|
||||
expect(response.body).toContain('"targetRevision":"16"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,12 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
import {
|
||||
executeUpdateHelperDeploy,
|
||||
executeUpdateHelperRollback,
|
||||
getUpdateHelperStatus,
|
||||
type UpdateHelperDeployInput,
|
||||
type UpdateHelperDeploySummary,
|
||||
type UpdateHelperRollbackInput,
|
||||
type UpdateHelperRollbackSummary,
|
||||
type UpdateHelperStatus,
|
||||
} from './service.js';
|
||||
|
||||
@@ -15,6 +18,10 @@ type BuildUpdateHelperAppOptions = {
|
||||
input: UpdateHelperDeployInput,
|
||||
onLog?: (message: string) => void,
|
||||
) => Promise<UpdateHelperDeploySummary>;
|
||||
rollback?: (
|
||||
input: UpdateHelperRollbackInput,
|
||||
onLog?: (message: string) => void,
|
||||
) => Promise<UpdateHelperRollbackSummary>;
|
||||
};
|
||||
|
||||
function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) {
|
||||
@@ -34,7 +41,16 @@ function toDeployInput(body: Record<string, unknown>): UpdateHelperDeployInput {
|
||||
chartRef: String(body.chartRef || '').trim(),
|
||||
imageRepository: String(body.imageRepository || '').trim(),
|
||||
targetSource: body.source === 'docker-hub-tag' ? 'docker-hub-tag' : 'github-release',
|
||||
targetTag: String(body.targetVersion || '').trim(),
|
||||
targetTag: String(body.targetTag || body.targetVersion || '').trim(),
|
||||
targetDigest: String(body.targetDigest || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function toRollbackInput(body: Record<string, unknown>): UpdateHelperRollbackInput {
|
||||
return {
|
||||
namespace: String(body.namespace || '').trim(),
|
||||
releaseName: String(body.releaseName || '').trim(),
|
||||
targetRevision: String(body.targetRevision || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +58,7 @@ export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions)
|
||||
const app = Fastify();
|
||||
const getStatus = options.getStatus || (async (input) => await getUpdateHelperStatus(input));
|
||||
const deploy = options.deploy || (async (input, onLog) => await executeUpdateHelperDeploy(input, undefined, onLog));
|
||||
const rollback = options.rollback || (async (input, onLog) => await executeUpdateHelperRollback(input, undefined, onLog));
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const path = String(request.raw.url || '').split('?')[0];
|
||||
@@ -75,7 +92,7 @@ export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions)
|
||||
if (!input.namespace || !input.releaseName || !input.chartRef || !input.imageRepository || !input.targetTag) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: 'namespace, releaseName, chartRef, imageRepository, and targetVersion are required',
|
||||
message: 'namespace, releaseName, chartRef, imageRepository, and targetTag are required',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,5 +113,31 @@ export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions)
|
||||
reply.raw.end();
|
||||
});
|
||||
|
||||
app.post<{ Body: Record<string, unknown> }>('/rollback', async (request, reply) => {
|
||||
const input = toRollbackInput(request.body || {});
|
||||
if (!input.namespace || !input.releaseName || !input.targetRevision) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: 'namespace, releaseName, and targetRevision are required',
|
||||
});
|
||||
}
|
||||
|
||||
const wantsSse = String(request.headers.accept || '').includes('text/event-stream');
|
||||
if (!wantsSse) {
|
||||
return await rollback(input);
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
|
||||
const result = await rollback(input, (message) => {
|
||||
writeSseEvent(reply, 'log', { message });
|
||||
});
|
||||
writeSseEvent(reply, 'result', result);
|
||||
reply.raw.end();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
executeUpdateHelperDeploy,
|
||||
executeUpdateHelperRollback,
|
||||
getUpdateHelperStatus,
|
||||
} from './service.js';
|
||||
|
||||
@@ -16,6 +17,7 @@ describe('update helper service', () => {
|
||||
imageRepository: '1467078763/metapi',
|
||||
targetSource: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
targetDigest: null,
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
@@ -55,6 +57,8 @@ describe('update helper service', () => {
|
||||
'image.repository=1467078763/metapi',
|
||||
'--set',
|
||||
'image.tag=1.3.0',
|
||||
'--set-string',
|
||||
'image.digest=',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -79,12 +83,69 @@ describe('update helper service', () => {
|
||||
success: true,
|
||||
targetSource: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
targetDigest: null,
|
||||
previousRevision: '12',
|
||||
finalRevision: '13',
|
||||
rolledBack: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes image digest overrides through helm when a digest-aware deployment is requested', async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const summary = await executeUpdateHelperDeploy(
|
||||
{
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
targetSource: 'docker-hub-tag',
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'helm' && args[0] === 'history') {
|
||||
return {
|
||||
stdout: JSON.stringify([{ revision: '18' }]),
|
||||
};
|
||||
}
|
||||
if (command === 'helm' && args[0] === 'status') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
version: 19,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { stdout: '' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toContainEqual({
|
||||
command: 'helm',
|
||||
args: [
|
||||
'upgrade',
|
||||
'metapi',
|
||||
'oci://ghcr.io/cita-777/charts/metapi',
|
||||
'--namespace',
|
||||
'ai',
|
||||
'--reuse-values',
|
||||
'--set',
|
||||
'image.repository=1467078763/metapi',
|
||||
'--set',
|
||||
'image.tag=latest',
|
||||
'--set-string',
|
||||
'image.digest=sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
],
|
||||
});
|
||||
expect(summary).toMatchObject({
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
finalRevision: '19',
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back to the previous revision when rollout verification fails', async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
|
||||
@@ -96,6 +157,7 @@ describe('update helper service', () => {
|
||||
imageRepository: '1467078763/metapi',
|
||||
targetSource: 'docker-hub-tag',
|
||||
targetTag: '1.3.1',
|
||||
targetDigest: null,
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
@@ -121,7 +183,61 @@ describe('update helper service', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('parses helper status from helm status and values output', async () => {
|
||||
it('runs an explicit rollback workflow for a chosen revision', async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const summary = await executeUpdateHelperRollback(
|
||||
{
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
targetRevision: '16',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'helm' && args[0] === 'status') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
version: 20,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { stdout: '' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: 'helm',
|
||||
args: ['rollback', 'metapi', '16', '-n', 'ai'],
|
||||
},
|
||||
{
|
||||
command: 'kubectl',
|
||||
args: [
|
||||
'rollout',
|
||||
'status',
|
||||
'deployment',
|
||||
'-n',
|
||||
'ai',
|
||||
'-l',
|
||||
'app.kubernetes.io/instance=metapi',
|
||||
'--timeout=300s',
|
||||
],
|
||||
},
|
||||
{
|
||||
command: 'helm',
|
||||
args: ['status', 'metapi', '-n', 'ai', '-o', 'json'],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
success: true,
|
||||
targetRevision: '16',
|
||||
finalRevision: '20',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses helper status from helm status, runtime image ids, and recent helm history', async () => {
|
||||
const status = await getUpdateHelperStatus(
|
||||
{
|
||||
namespace: 'ai',
|
||||
@@ -140,15 +256,65 @@ describe('update helper service', () => {
|
||||
};
|
||||
}
|
||||
if (command === 'helm' && args[0] === 'get') {
|
||||
const revision = args.includes('--revision')
|
||||
? args[args.indexOf('--revision') + 1]
|
||||
: null;
|
||||
if (revision === '16') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
image: {
|
||||
repository: '1467078763/metapi',
|
||||
tag: '1.3.1',
|
||||
tag: 'latest',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (revision === '17') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
image: {
|
||||
repository: '1467078763/metapi',
|
||||
tag: 'main',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
image: {
|
||||
repository: '1467078763/metapi',
|
||||
tag: 'latest',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'helm' && args[0] === 'history') {
|
||||
return {
|
||||
stdout: JSON.stringify([
|
||||
{ revision: '17', updated: '2026-03-29T12:00:00Z', status: 'deployed', description: 'Upgrade complete' },
|
||||
{ revision: '16', updated: '2026-03-28T12:00:00Z', status: 'superseded', description: 'Rollback to stable digest' },
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (command === 'kubectl' && args[0] === 'get') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
status: {
|
||||
containerStatuses: [
|
||||
{
|
||||
imageID: 'docker-pullable://1467078763/metapi@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error('unexpected command');
|
||||
},
|
||||
},
|
||||
@@ -160,8 +326,29 @@ describe('update helper service', () => {
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.3.1',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
healthy: true,
|
||||
history: [
|
||||
{
|
||||
revision: '17',
|
||||
updatedAt: '2026-03-29T12:00:00Z',
|
||||
status: 'deployed',
|
||||
description: 'Upgrade complete',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: 'main',
|
||||
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
{
|
||||
revision: '16',
|
||||
updatedAt: '2026-03-28T12:00:00Z',
|
||||
status: 'superseded',
|
||||
description: 'Rollback to stable digest',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,13 @@ export type UpdateHelperDeployInput = {
|
||||
imageRepository: string;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
targetDigest?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateHelperRollbackInput = {
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
targetRevision: string;
|
||||
};
|
||||
|
||||
export type UpdateHelperStatusInput = {
|
||||
@@ -33,12 +40,30 @@ export type UpdateHelperDeploySummary = {
|
||||
success: boolean;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
targetDigest: string | null;
|
||||
previousRevision: string | null;
|
||||
finalRevision: string | null;
|
||||
rolledBack: boolean;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
export type UpdateHelperRollbackSummary = {
|
||||
success: boolean;
|
||||
targetRevision: string;
|
||||
finalRevision: string | null;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
export type UpdateHelperHistoryEntry = {
|
||||
revision: string;
|
||||
updatedAt: string | null;
|
||||
status: string | null;
|
||||
description: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
imageDigest: string | null;
|
||||
};
|
||||
|
||||
export type UpdateHelperStatus = {
|
||||
ok: boolean;
|
||||
releaseName: string;
|
||||
@@ -46,7 +71,15 @@ export type UpdateHelperStatus = {
|
||||
revision: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
imageDigest: string | null;
|
||||
healthy: boolean;
|
||||
history: UpdateHelperHistoryEntry[];
|
||||
};
|
||||
|
||||
type ParsedReleaseValues = {
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
imageDigest: string | null;
|
||||
};
|
||||
|
||||
async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
|
||||
@@ -94,9 +127,13 @@ async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
|
||||
function parseLatestRevision(stdout: string): string | null {
|
||||
try {
|
||||
const rows = JSON.parse(stdout) as Array<{ revision?: string | number }>;
|
||||
const last = Array.isArray(rows) && rows.length > 0 ? rows[rows.length - 1] : null;
|
||||
if (!last?.revision) return null;
|
||||
return String(last.revision);
|
||||
const revisions = rows
|
||||
.map((row) => String(row?.revision || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter(Number.isFinite);
|
||||
if (revisions.length <= 0) return null;
|
||||
return String(Math.max(...revisions));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -112,6 +149,113 @@ function parseStatusRevision(stdout: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function parseReleaseValues(stdout: string): ParsedReleaseValues {
|
||||
try {
|
||||
const payload = JSON.parse(stdout || '{}') as {
|
||||
image?: {
|
||||
repository?: string;
|
||||
tag?: string;
|
||||
digest?: string;
|
||||
};
|
||||
};
|
||||
return {
|
||||
imageRepository: payload.image?.repository || null,
|
||||
imageTag: payload.image?.tag || null,
|
||||
imageDigest: payload.image?.digest || null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
imageRepository: null,
|
||||
imageTag: null,
|
||||
imageDigest: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseRuntimeImageDigest(stdout: string): string | null {
|
||||
try {
|
||||
const payload = JSON.parse(stdout || '{}') as {
|
||||
items?: Array<{
|
||||
status?: {
|
||||
containerStatuses?: Array<{
|
||||
imageID?: string | null;
|
||||
image?: string | null;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
for (const item of payload.items || []) {
|
||||
for (const status of item.status?.containerStatuses || []) {
|
||||
const candidate = `${status.imageID || ''} ${status.image || ''}`;
|
||||
const match = candidate.match(/(sha256:[a-f0-9]{64})/i);
|
||||
if (match?.[1]) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHistoryRows(stdout: string): Array<{
|
||||
revision: string;
|
||||
updatedAt: string | null;
|
||||
status: string | null;
|
||||
description: string | null;
|
||||
}> {
|
||||
try {
|
||||
const rows = JSON.parse(stdout || '[]') as Array<{
|
||||
revision?: string | number;
|
||||
updated?: string | null;
|
||||
status?: string | null;
|
||||
description?: string | null;
|
||||
}>;
|
||||
return rows
|
||||
.map((row) => ({
|
||||
revision: String(row?.revision || '').trim(),
|
||||
updatedAt: String(row?.updated || '').trim() || null,
|
||||
status: String(row?.status || '').trim() || null,
|
||||
description: String(row?.description || '').trim() || null,
|
||||
}))
|
||||
.filter((row) => row.revision);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildRolloutStatusArgs(input: { namespace: string; releaseName: string }): string[] {
|
||||
return [
|
||||
'rollout',
|
||||
'status',
|
||||
'deployment',
|
||||
'-n',
|
||||
input.namespace,
|
||||
'-l',
|
||||
`app.kubernetes.io/instance=${input.releaseName}`,
|
||||
'--timeout=300s',
|
||||
];
|
||||
}
|
||||
|
||||
function buildHelmUpgradeArgs(input: UpdateHelperDeployInput): string[] {
|
||||
return [
|
||||
'upgrade',
|
||||
input.releaseName,
|
||||
input.chartRef,
|
||||
'--namespace',
|
||||
input.namespace,
|
||||
'--reuse-values',
|
||||
'--set',
|
||||
`image.repository=${input.imageRepository}`,
|
||||
'--set',
|
||||
`image.tag=${input.targetTag}`,
|
||||
'--set-string',
|
||||
`image.digest=${input.targetDigest || ''}`,
|
||||
];
|
||||
}
|
||||
|
||||
export async function executeUpdateHelperDeploy(
|
||||
input: UpdateHelperDeployInput,
|
||||
deps: {
|
||||
@@ -134,34 +278,14 @@ export async function executeUpdateHelperDeploy(
|
||||
|
||||
await commandRunner({
|
||||
command: 'helm',
|
||||
args: [
|
||||
'upgrade',
|
||||
input.releaseName,
|
||||
input.chartRef,
|
||||
'--namespace',
|
||||
input.namespace,
|
||||
'--reuse-values',
|
||||
'--set',
|
||||
`image.repository=${input.imageRepository}`,
|
||||
'--set',
|
||||
`image.tag=${input.targetTag}`,
|
||||
],
|
||||
args: buildHelmUpgradeArgs(input),
|
||||
onLine: captureLine,
|
||||
});
|
||||
|
||||
try {
|
||||
await commandRunner({
|
||||
command: 'kubectl',
|
||||
args: [
|
||||
'rollout',
|
||||
'status',
|
||||
'deployment',
|
||||
'-n',
|
||||
input.namespace,
|
||||
'-l',
|
||||
`app.kubernetes.io/instance=${input.releaseName}`,
|
||||
'--timeout=300s',
|
||||
],
|
||||
args: buildRolloutStatusArgs(input),
|
||||
onLine: captureLine,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -184,6 +308,7 @@ export async function executeUpdateHelperDeploy(
|
||||
success: true,
|
||||
targetSource: input.targetSource,
|
||||
targetTag: input.targetTag,
|
||||
targetDigest: input.targetDigest || null,
|
||||
previousRevision,
|
||||
finalRevision: parseStatusRevision(status.stdout),
|
||||
rolledBack: false,
|
||||
@@ -191,6 +316,45 @@ export async function executeUpdateHelperDeploy(
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeUpdateHelperRollback(
|
||||
input: UpdateHelperRollbackInput,
|
||||
deps: {
|
||||
runCommand?: RunCommand;
|
||||
} = {},
|
||||
onLog?: (line: string) => void,
|
||||
): Promise<UpdateHelperRollbackSummary> {
|
||||
const commandRunner = deps.runCommand || runCommand;
|
||||
const logLines: string[] = [];
|
||||
const captureLine = (line: string) => {
|
||||
logLines.push(line);
|
||||
onLog?.(line);
|
||||
};
|
||||
|
||||
await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['rollback', input.releaseName, input.targetRevision, '-n', input.namespace],
|
||||
onLine: captureLine,
|
||||
});
|
||||
|
||||
await commandRunner({
|
||||
command: 'kubectl',
|
||||
args: buildRolloutStatusArgs(input),
|
||||
onLine: captureLine,
|
||||
});
|
||||
|
||||
const status = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetRevision: input.targetRevision,
|
||||
finalRevision: parseStatusRevision(status.stdout),
|
||||
logLines,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUpdateHelperStatus(
|
||||
input: UpdateHelperStatusInput,
|
||||
deps: {
|
||||
@@ -198,14 +362,24 @@ export async function getUpdateHelperStatus(
|
||||
} = {},
|
||||
): Promise<UpdateHelperStatus> {
|
||||
const commandRunner = deps.runCommand || runCommand;
|
||||
const status = await commandRunner({
|
||||
const [status, values, history, pods] = await Promise.all([
|
||||
commandRunner({
|
||||
command: 'helm',
|
||||
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
});
|
||||
const values = await commandRunner({
|
||||
}),
|
||||
commandRunner({
|
||||
command: 'helm',
|
||||
args: ['get', 'values', input.releaseName, '-n', input.namespace, '--all', '-o', 'json'],
|
||||
});
|
||||
}),
|
||||
commandRunner({
|
||||
command: 'helm',
|
||||
args: ['history', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
}),
|
||||
commandRunner({
|
||||
command: 'kubectl',
|
||||
args: ['get', 'pods', '-n', input.namespace, '-l', `app.kubernetes.io/instance=${input.releaseName}`, '-o', 'json'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const statusPayload = JSON.parse(status.stdout || '{}') as {
|
||||
version?: string | number;
|
||||
@@ -213,20 +387,47 @@ export async function getUpdateHelperStatus(
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
const valuesPayload = JSON.parse(values.stdout || '{}') as {
|
||||
image?: {
|
||||
repository?: string;
|
||||
tag?: string;
|
||||
};
|
||||
const currentValues = parseReleaseValues(values.stdout);
|
||||
const runtimeDigest = parseRuntimeImageDigest(pods.stdout);
|
||||
const historyRows = parseHistoryRows(history.stdout).slice(0, 5);
|
||||
|
||||
const historyEntries: UpdateHelperHistoryEntry[] = [];
|
||||
for (const row of historyRows) {
|
||||
let releaseValues: ParsedReleaseValues = {
|
||||
imageRepository: null,
|
||||
imageTag: null,
|
||||
imageDigest: null,
|
||||
};
|
||||
try {
|
||||
const result = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['get', 'values', input.releaseName, '-n', input.namespace, '--revision', row.revision, '--all', '-o', 'json'],
|
||||
});
|
||||
releaseValues = parseReleaseValues(result.stdout);
|
||||
} catch {
|
||||
// Best effort: history should still render even if a revision payload cannot be read.
|
||||
}
|
||||
|
||||
historyEntries.push({
|
||||
revision: row.revision,
|
||||
updatedAt: row.updatedAt,
|
||||
status: row.status,
|
||||
description: row.description,
|
||||
imageRepository: releaseValues.imageRepository,
|
||||
imageTag: releaseValues.imageTag,
|
||||
imageDigest: releaseValues.imageDigest,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
releaseName: input.releaseName,
|
||||
namespace: input.namespace,
|
||||
revision: statusPayload.version === undefined || statusPayload.version === null ? null : String(statusPayload.version),
|
||||
imageRepository: valuesPayload.image?.repository || null,
|
||||
imageTag: valuesPayload.image?.tag || null,
|
||||
imageRepository: currentValues.imageRepository,
|
||||
imageTag: currentValues.imageTag,
|
||||
imageDigest: runtimeDigest || currentValues.imageDigest,
|
||||
healthy: String(statusPayload.info?.status || '').toLowerCase() === 'deployed',
|
||||
history: historyEntries,
|
||||
};
|
||||
}
|
||||
|
||||
+9
-1
@@ -760,7 +760,15 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
deployUpdateCenter: (data: { source: 'github-release' | 'docker-hub-tag'; targetVersion: string }) => request('/api/update-center/deploy', {
|
||||
deployUpdateCenter: (data: {
|
||||
source: 'github-release' | 'docker-hub-tag';
|
||||
targetTag: string;
|
||||
targetDigest?: string | null;
|
||||
}) => request('/api/update-center/deploy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
rollbackUpdateCenter: (data: { targetRevision: string }) => request('/api/update-center/rollback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
@@ -45,15 +45,15 @@ export default function About() {
|
||||
try {
|
||||
const status = await api.getUpdateCenterStatus() as {
|
||||
currentVersion?: string;
|
||||
githubRelease?: { normalizedVersion?: string } | null;
|
||||
dockerHubTag?: { normalizedVersion?: string } | null;
|
||||
githubRelease?: { normalizedVersion?: string; displayVersion?: string } | null;
|
||||
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string } | null;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (status.currentVersion) {
|
||||
setCurrentVersion(`v${status.currentVersion}`);
|
||||
}
|
||||
setLatestGitHubVersion(String(status.githubRelease?.normalizedVersion || ''));
|
||||
setLatestDockerHubVersion(String(status.dockerHubTag?.normalizedVersion || ''));
|
||||
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
|
||||
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
|
||||
} catch {
|
||||
// ignore update-center lookup failures on about page
|
||||
}
|
||||
|
||||
@@ -35,10 +35,12 @@ describe('About update center', () => {
|
||||
currentVersion: '1.2.3',
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
normalizedVersion: 'latest',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -64,7 +66,7 @@ describe('About update center', () => {
|
||||
expect(text).toContain('GitHub 稳定版');
|
||||
expect(text).toContain('1.3.0');
|
||||
expect(text).toContain('Docker Hub');
|
||||
expect(text).toContain('1.3.1');
|
||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||
expect(text).toContain('前往更新中心');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
|
||||
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
saveUpdateCenterConfig: vi.fn(),
|
||||
checkUpdateCenter: vi.fn(),
|
||||
deployUpdateCenter: vi.fn(),
|
||||
rollbackUpdateCenter: vi.fn(),
|
||||
streamUpdateCenterTaskLogs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -57,13 +58,31 @@ describe('UpdateCenterSection', () => {
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
history: [
|
||||
{
|
||||
revision: '16',
|
||||
updatedAt: '2026-03-28T12:00:00Z',
|
||||
status: 'superseded',
|
||||
description: 'Rollback to stable digest',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: 'main',
|
||||
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
],
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
@@ -85,9 +104,14 @@ describe('UpdateCenterSection', () => {
|
||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
});
|
||||
apiMock.deployUpdateCenter.mockResolvedValue({
|
||||
@@ -97,6 +121,13 @@ describe('UpdateCenterSection', () => {
|
||||
id: 'task-1',
|
||||
},
|
||||
});
|
||||
apiMock.rollbackUpdateCenter.mockResolvedValue({
|
||||
success: true,
|
||||
reused: false,
|
||||
task: {
|
||||
id: 'task-2',
|
||||
},
|
||||
});
|
||||
apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => {
|
||||
handlers.onLog?.({ message: 'Running helm upgrade' });
|
||||
handlers.onLog?.({ message: 'Waiting for rollout' });
|
||||
@@ -190,11 +221,13 @@ describe('UpdateCenterSection', () => {
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
targetTag: '1.3.0',
|
||||
targetDigest: null,
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||
expect(text).toContain('Running helm upgrade');
|
||||
expect(text).toContain('Waiting for rollout');
|
||||
expect(text).toContain('任务状态 · 已完成');
|
||||
@@ -221,7 +254,8 @@ describe('UpdateCenterSection', () => {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
normalizedVersion: 'latest',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
helper: {
|
||||
ok: false,
|
||||
@@ -271,4 +305,42 @@ describe('UpdateCenterSection', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rollbackButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('回退到 revision 16')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await rollbackButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.rollbackUpdateCenter).toHaveBeenCalledWith({
|
||||
targetRevision: '16',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object));
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('sha256:bbbbbbbbbbbb');
|
||||
expect(text).toContain('Rollback to stable digest');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,14 +20,34 @@ type UpdateCenterStatus = {
|
||||
};
|
||||
githubRelease?: {
|
||||
normalizedVersion?: string;
|
||||
displayVersion?: string;
|
||||
tagName?: string;
|
||||
digest?: string | null;
|
||||
publishedAt?: string | null;
|
||||
} | null;
|
||||
dockerHubTag?: {
|
||||
normalizedVersion?: string;
|
||||
displayVersion?: string;
|
||||
tagName?: string;
|
||||
digest?: string | null;
|
||||
publishedAt?: string | null;
|
||||
} | null;
|
||||
helper?: {
|
||||
ok?: boolean;
|
||||
healthy?: boolean;
|
||||
error?: string | null;
|
||||
revision?: string | null;
|
||||
imageTag?: string | null;
|
||||
imageDigest?: string | null;
|
||||
history?: Array<{
|
||||
revision?: string;
|
||||
updatedAt?: string | null;
|
||||
status?: string | null;
|
||||
description?: string | null;
|
||||
imageRepository?: string | null;
|
||||
imageTag?: string | null;
|
||||
imageDigest?: string | null;
|
||||
}>;
|
||||
} | null;
|
||||
runningTask?: {
|
||||
id?: string;
|
||||
@@ -158,6 +178,23 @@ function getSourceBadge(enabled: boolean, version?: string) {
|
||||
return { className: 'badge badge-warning', label: '未发现版本' };
|
||||
}
|
||||
|
||||
function formatShortDigest(digest?: string | null) {
|
||||
const value = String(digest || '').trim();
|
||||
if (!value) return '';
|
||||
return value.slice(0, 'sha256:'.length + 12);
|
||||
}
|
||||
|
||||
function formatImageTarget(tag?: string | null, digest?: string | null) {
|
||||
const normalizedTag = String(tag || '').trim();
|
||||
const shortDigest = formatShortDigest(digest);
|
||||
if (normalizedTag && shortDigest) {
|
||||
return `${normalizedTag} @ ${shortDigest}`;
|
||||
}
|
||||
if (normalizedTag) return normalizedTag;
|
||||
if (shortDigest) return shortDigest;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function UpdateCenterSection() {
|
||||
const toast = useToast();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -223,24 +260,9 @@ export default function UpdateCenterSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const runDeploy = async (source: 'github-release' | 'docker-hub-tag', targetVersion: string) => {
|
||||
if (!targetVersion) return;
|
||||
setDeploying(true);
|
||||
setLogs([]);
|
||||
setTaskStatus('running');
|
||||
streamAbortRef.current?.abort();
|
||||
streamAbortRef.current = new AbortController();
|
||||
let taskId = '';
|
||||
|
||||
try {
|
||||
const response = await api.deployUpdateCenter({ source, targetVersion }) as { task?: { id: string } };
|
||||
taskId = response.task?.id || '';
|
||||
if (!taskId) {
|
||||
throw new Error('部署任务未返回 taskId');
|
||||
}
|
||||
|
||||
const streamTaskLogs = async (taskId: string) => {
|
||||
await api.streamUpdateCenterTaskLogs(taskId, {
|
||||
signal: streamAbortRef.current.signal,
|
||||
signal: streamAbortRef.current?.signal,
|
||||
onLog: (entry) => {
|
||||
const message = String(entry?.message || '').trim();
|
||||
if (!message) return;
|
||||
@@ -250,15 +272,47 @@ export default function UpdateCenterSection() {
|
||||
setTaskStatus(String(payload?.status || 'unknown'));
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (taskId) {
|
||||
try {
|
||||
};
|
||||
|
||||
const hydrateTaskSnapshot = async (taskId: string) => {
|
||||
const taskResponse = await api.getTask(taskId) as { task?: { status?: string; logs?: Array<{ message?: string }> } };
|
||||
const task = taskResponse.task;
|
||||
if (task) {
|
||||
if (!task) return false;
|
||||
setTaskStatus(String(task.status || 'unknown'));
|
||||
setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []);
|
||||
toast.info('实时日志流已断开,已回退到任务详情快照');
|
||||
return true;
|
||||
};
|
||||
|
||||
const runDeploy = async (
|
||||
source: 'github-release' | 'docker-hub-tag',
|
||||
target: { tag?: string | null; digest?: string | null },
|
||||
) => {
|
||||
const targetTag = String(target.tag || '').trim();
|
||||
if (!targetTag) return;
|
||||
setDeploying(true);
|
||||
setLogs([]);
|
||||
setTaskStatus('running');
|
||||
streamAbortRef.current?.abort();
|
||||
streamAbortRef.current = new AbortController();
|
||||
let taskId = '';
|
||||
|
||||
try {
|
||||
const response = await api.deployUpdateCenter({
|
||||
source,
|
||||
targetTag,
|
||||
targetDigest: target.digest || null,
|
||||
}) as { task?: { id: string } };
|
||||
taskId = response.task?.id || '';
|
||||
if (!taskId) {
|
||||
throw new Error('部署任务未返回 taskId');
|
||||
}
|
||||
|
||||
await streamTaskLogs(taskId);
|
||||
} catch (error: any) {
|
||||
if (taskId) {
|
||||
try {
|
||||
if (await hydrateTaskSnapshot(taskId)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -273,6 +327,41 @@ export default function UpdateCenterSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const runRollback = async (targetRevision: string) => {
|
||||
if (!targetRevision) return;
|
||||
setDeploying(true);
|
||||
setLogs([]);
|
||||
setTaskStatus('running');
|
||||
streamAbortRef.current?.abort();
|
||||
streamAbortRef.current = new AbortController();
|
||||
let taskId = '';
|
||||
|
||||
try {
|
||||
const response = await api.rollbackUpdateCenter({ targetRevision }) as { task?: { id: string } };
|
||||
taskId = response.task?.id || '';
|
||||
if (!taskId) {
|
||||
throw new Error('回退任务未返回 taskId');
|
||||
}
|
||||
|
||||
await streamTaskLogs(taskId);
|
||||
} catch (error: any) {
|
||||
if (taskId) {
|
||||
try {
|
||||
if (await hydrateTaskSnapshot(taskId)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the generic error state
|
||||
}
|
||||
}
|
||||
setTaskStatus('failed');
|
||||
toast.error(error?.message || '回退失败');
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
void loadStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const helperHealthy = !!status?.helper?.healthy;
|
||||
const helperBadge = getHelperBadge(status?.helper, config.helperBaseUrl);
|
||||
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
|
||||
@@ -530,7 +619,7 @@ export default function UpdateCenterSection() {
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>优先使用仓库稳定版 release,适合保留语义化版本节奏。</div>
|
||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||
{status?.githubRelease?.normalizedVersion || '未发现'}
|
||||
{status?.githubRelease?.displayVersion || status?.githubRelease?.normalizedVersion || '未发现'}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
|
||||
@@ -539,7 +628,10 @@ export default function UpdateCenterSection() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!helperHealthy) return;
|
||||
void runDeploy('github-release', status?.githubRelease?.normalizedVersion || '');
|
||||
void runDeploy('github-release', {
|
||||
tag: status?.githubRelease?.tagName || status?.githubRelease?.normalizedVersion || '',
|
||||
digest: null,
|
||||
});
|
||||
}}
|
||||
disabled={!canDeployGithub}
|
||||
className={config.defaultDeploySource === 'github-release' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
@@ -563,16 +655,22 @@ export default function UpdateCenterSection() {
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</div>
|
||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||
{status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!helperHealthy) return;
|
||||
void runDeploy('docker-hub-tag', status?.dockerHubTag?.normalizedVersion || '');
|
||||
void runDeploy('docker-hub-tag', {
|
||||
tag: status?.dockerHubTag?.tagName || status?.dockerHubTag?.normalizedVersion || '',
|
||||
digest: status?.dockerHubTag?.digest || null,
|
||||
});
|
||||
}}
|
||||
disabled={!canDeployDocker}
|
||||
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
@@ -599,6 +697,9 @@ export default function UpdateCenterSection() {
|
||||
<div style={fieldHintStyle}>
|
||||
{status?.helper?.error || 'Helper 正常时会先执行 helm upgrade,再等待 kubectl rollout status。'}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginTop: 6 }}>
|
||||
当前镜像:{formatImageTarget(status?.helper?.imageTag, status?.helper?.imageDigest) || '等待 helper 返回运行中镜像'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
@@ -626,6 +727,76 @@ export default function UpdateCenterSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>回退历史</div>
|
||||
<span className="badge badge-muted">最近 revision</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
这里展示 helper 读到的最近 Helm revision。支持直接回退到某个旧 revision,适合快速滚回上一次稳定镜像。
|
||||
</div>
|
||||
{status?.helper?.history && status.helper.history.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{status.helper.history.map((entry) => {
|
||||
const revision = String(entry?.revision || '').trim();
|
||||
const isCurrentRevision = revision && revision === String(status?.helper?.revision || '').trim();
|
||||
return (
|
||||
<div
|
||||
key={revision || 'unknown-revision'}
|
||||
style={{
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 6,
|
||||
background: 'var(--color-bg-card)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
revision {revision || '-'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{entry?.status ? (
|
||||
<span className="badge badge-muted">{entry.status}</span>
|
||||
) : null}
|
||||
{isCurrentRevision ? (
|
||||
<span className="badge badge-info">当前运行</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', fontSize: 13 }}>
|
||||
{formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'}
|
||||
</div>
|
||||
{entry?.description ? (
|
||||
<div style={fieldHintStyle}>{entry.description}</div>
|
||||
) : null}
|
||||
<div style={fieldHintStyle}>更新时间:{formatTaskTime(entry?.updatedAt)}</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isCurrentRevision) return;
|
||||
void runRollback(revision);
|
||||
}}
|
||||
disabled={!helperHealthy || deploying || isCurrentRevision || !revision}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
回退到 revision {revision}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={fieldHintStyle}>
|
||||
Helper 还没有返回可回退的 revision 历史。至少成功部署过一次后,这里才会稳定显示历史记录。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>部署日志</div>
|
||||
|
||||
Reference in New Issue
Block a user