Support digest-aware update center rollbacks (#318)
This commit is contained in:
@@ -113,9 +113,10 @@ docker compose up -d
|
|||||||
|
|
||||||
- 一个可用的 K3s / Kubernetes 集群
|
- 一个可用的 K3s / Kubernetes 集群
|
||||||
- 用 Helm 部署的 Metapi release
|
- 用 Helm 部署的 Metapi release
|
||||||
- 你的 chart 支持下面两个 values:
|
- 你的 chart 至少支持下面三个 values:
|
||||||
- `image.repository`
|
- `image.repository`
|
||||||
- `image.tag`
|
- `image.tag`
|
||||||
|
- `image.digest`
|
||||||
- 目标 Deployment 带有 `app.kubernetes.io/instance=<releaseName>` 标签
|
- 目标 Deployment 带有 `app.kubernetes.io/instance=<releaseName>` 标签
|
||||||
- 主 Metapi 服务可以访问:
|
- 主 Metapi 服务可以访问:
|
||||||
- GitHub API
|
- GitHub API
|
||||||
@@ -129,6 +130,7 @@ docker compose up -d
|
|||||||
Deploy Helper 是一个跑在集群里的小服务。它不负责对外提供 Metapi 功能,只负责接受主 Metapi 发来的部署请求,然后在集群里执行:
|
Deploy Helper 是一个跑在集群里的小服务。它不负责对外提供 Metapi 功能,只负责接受主 Metapi 发来的部署请求,然后在集群里执行:
|
||||||
|
|
||||||
- `helm history`
|
- `helm history`
|
||||||
|
- `helm get values`
|
||||||
- `helm upgrade`
|
- `helm upgrade`
|
||||||
- `kubectl rollout status`
|
- `kubectl rollout status`
|
||||||
- 必要时 `helm rollback`
|
- 必要时 `helm rollback`
|
||||||
@@ -223,9 +225,11 @@ helper 端使用:
|
|||||||
3. 确认页面状态都正常:
|
3. 确认页面状态都正常:
|
||||||
- 当前运行版本正常显示
|
- 当前运行版本正常显示
|
||||||
- 版本来源发现了可部署版本
|
- 版本来源发现了可部署版本
|
||||||
|
- 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest
|
||||||
- Deploy Helper 显示健康
|
- Deploy Helper 显示健康
|
||||||
4. 再点部署按钮
|
4. 再点部署按钮
|
||||||
5. 在页面下方看部署日志
|
5. 在页面下方看部署日志
|
||||||
|
6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像
|
||||||
|
|
||||||
如果实时日志流断开,页面会自动回退到最近任务快照。
|
如果实时日志流断开,页面会自动回退到最近任务快照。
|
||||||
|
|
||||||
@@ -258,6 +262,8 @@ About 页里的“更新提醒”更像一个轻量入口:
|
|||||||
- K3s / Kubernetes
|
- K3s / Kubernetes
|
||||||
- Helm release
|
- Helm release
|
||||||
- Deploy Helper
|
- Deploy Helper
|
||||||
|
|
||||||
|
另外,如果你想用“按 digest 精确部署/回退”这条链路,还要确认你的 chart 没有忽略 `image.digest` 这个值;否则页面虽然能显示 digest,真正部署时还是只会落到 tag 语义。
|
||||||
- 对齐的 token
|
- 对齐的 token
|
||||||
|
|
||||||
缺其中任何一项,都只能看,不能真正部署。
|
缺其中任何一项,都只能看,不能真正部署。
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ const {
|
|||||||
fetchLatestDockerHubTagMock,
|
fetchLatestDockerHubTagMock,
|
||||||
getUpdateCenterHelperStatusMock,
|
getUpdateCenterHelperStatusMock,
|
||||||
streamUpdateCenterDeployMock,
|
streamUpdateCenterDeployMock,
|
||||||
|
streamUpdateCenterRollbackMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
||||||
fetchLatestDockerHubTagMock: vi.fn(),
|
fetchLatestDockerHubTagMock: vi.fn(),
|
||||||
getUpdateCenterHelperStatusMock: vi.fn(),
|
getUpdateCenterHelperStatusMock: vi.fn(),
|
||||||
streamUpdateCenterDeployMock: vi.fn(),
|
streamUpdateCenterDeployMock: vi.fn(),
|
||||||
|
streamUpdateCenterRollbackMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/updateCenterVersionService.js', async () => {
|
vi.mock('../../services/updateCenterVersionService.js', async () => {
|
||||||
@@ -29,6 +31,7 @@ vi.mock('../../services/updateCenterVersionService.js', async () => {
|
|||||||
vi.mock('../../services/updateCenterHelperClient.js', () => ({
|
vi.mock('../../services/updateCenterHelperClient.js', () => ({
|
||||||
getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args),
|
getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args),
|
||||||
streamUpdateCenterDeploy: (...args: unknown[]) => streamUpdateCenterDeployMock(...args),
|
streamUpdateCenterDeploy: (...args: unknown[]) => streamUpdateCenterDeployMock(...args),
|
||||||
|
streamUpdateCenterRollback: (...args: unknown[]) => streamUpdateCenterRollbackMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
@@ -89,6 +92,7 @@ describe('update center routes', () => {
|
|||||||
fetchLatestDockerHubTagMock.mockReset();
|
fetchLatestDockerHubTagMock.mockReset();
|
||||||
getUpdateCenterHelperStatusMock.mockReset();
|
getUpdateCenterHelperStatusMock.mockReset();
|
||||||
streamUpdateCenterDeployMock.mockReset();
|
streamUpdateCenterDeployMock.mockReset();
|
||||||
|
streamUpdateCenterRollbackMock.mockReset();
|
||||||
resetBackgroundTasks?.();
|
resetBackgroundTasks?.();
|
||||||
|
|
||||||
await db.delete(schema.events).run();
|
await db.delete(schema.events).run();
|
||||||
@@ -112,8 +116,12 @@ describe('update center routes', () => {
|
|||||||
});
|
});
|
||||||
fetchLatestDockerHubTagMock.mockResolvedValue({
|
fetchLatestDockerHubTagMock.mockResolvedValue({
|
||||||
source: 'docker-hub-tag',
|
source: 'docker-hub-tag',
|
||||||
rawVersion: '1.3.1',
|
rawVersion: 'latest',
|
||||||
normalizedVersion: '1.3.1',
|
normalizedVersion: 'latest',
|
||||||
|
tagName: 'latest',
|
||||||
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
|
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||||
url: null,
|
url: null,
|
||||||
});
|
});
|
||||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||||
@@ -122,8 +130,20 @@ describe('update center routes', () => {
|
|||||||
namespace: 'ai',
|
namespace: 'ai',
|
||||||
revision: '12',
|
revision: '12',
|
||||||
imageRepository: '1467078763/metapi',
|
imageRepository: '1467078763/metapi',
|
||||||
imageTag: '1.2.3',
|
imageTag: 'latest',
|
||||||
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
healthy: true,
|
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({
|
const saveResponse = await app.inject({
|
||||||
@@ -164,12 +184,21 @@ describe('update center routes', () => {
|
|||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
},
|
},
|
||||||
dockerHubTag: {
|
dockerHubTag: {
|
||||||
normalizedVersion: '1.3.1',
|
normalizedVersion: 'latest',
|
||||||
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
healthy: true,
|
healthy: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
revision: '11',
|
||||||
|
imageTag: 'main',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -276,6 +305,7 @@ describe('update center routes', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
targetSource: 'github-release',
|
targetSource: 'github-release',
|
||||||
targetTag: '1.3.0',
|
targetTag: '1.3.0',
|
||||||
|
targetDigest: null,
|
||||||
previousRevision: '12',
|
previousRevision: '12',
|
||||||
finalRevision: '13',
|
finalRevision: '13',
|
||||||
rolledBack: false,
|
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 () => {
|
it('streams deployment logs for known tasks and rejects unknown task ids', async () => {
|
||||||
await saveValidConfig();
|
await saveValidConfig();
|
||||||
|
|
||||||
@@ -364,6 +450,7 @@ describe('update center routes', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
targetSource: 'docker-hub-tag',
|
targetSource: 'docker-hub-tag',
|
||||||
targetTag: '1.3.1',
|
targetTag: '1.3.1',
|
||||||
|
targetDigest: null,
|
||||||
previousRevision: '13',
|
previousRevision: '13',
|
||||||
finalRevision: '14',
|
finalRevision: '14',
|
||||||
rolledBack: false,
|
rolledBack: false,
|
||||||
|
|||||||
@@ -25,11 +25,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
getUpdateCenterHelperStatus,
|
getUpdateCenterHelperStatus,
|
||||||
streamUpdateCenterDeploy,
|
streamUpdateCenterDeploy,
|
||||||
|
streamUpdateCenterRollback,
|
||||||
} from '../../services/updateCenterHelperClient.js';
|
} from '../../services/updateCenterHelperClient.js';
|
||||||
|
|
||||||
type DeployBody = {
|
type DeployBody = {
|
||||||
source?: UpdateCenterVersionSource;
|
source?: UpdateCenterVersionSource;
|
||||||
targetVersion?: string;
|
targetVersion?: string;
|
||||||
|
targetTag?: string;
|
||||||
|
targetDigest?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RollbackBody = {
|
||||||
|
targetRevision?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
|
const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
|
||||||
@@ -44,14 +51,18 @@ function getUpdateCenterHelperToken(): string {
|
|||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDeployableConfig(config: UpdateCenterConfig) {
|
function assertHelperConfig(config: UpdateCenterConfig) {
|
||||||
if (!config.enabled) throw new Error('update center is disabled');
|
if (!config.enabled) throw new Error('update center is disabled');
|
||||||
if (!config.helperBaseUrl) throw new Error('helperBaseUrl is required');
|
if (!config.helperBaseUrl) throw new Error('helperBaseUrl is required');
|
||||||
if (!config.namespace) throw new Error('namespace is required');
|
if (!config.namespace) throw new Error('namespace is required');
|
||||||
if (!config.releaseName) throw new Error('releaseName 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.chartRef) throw new Error('chartRef is required');
|
||||||
if (!config.imageRepository) throw new Error('imageRepository is required');
|
if (!config.imageRepository) throw new Error('imageRepository is required');
|
||||||
if (!getUpdateCenterHelperToken()) throw new Error('DEPLOY_HELPER_TOKEN is required');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDeployTasks() {
|
function getDeployTasks() {
|
||||||
@@ -163,11 +174,12 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
|||||||
: request.body?.source === 'github-release'
|
: request.body?.source === 'github-release'
|
||||||
? 'github-release'
|
? 'github-release'
|
||||||
: config.defaultDeploySource;
|
: config.defaultDeploySource;
|
||||||
const targetVersion = String(request.body?.targetVersion || '').trim();
|
const targetTag = String(request.body?.targetTag || request.body?.targetVersion || '').trim();
|
||||||
if (!targetVersion) {
|
const targetDigest = String(request.body?.targetDigest || '').trim() || null;
|
||||||
|
if (!targetTag) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'targetVersion is required',
|
message: 'targetTag is required',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +194,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
appendBackgroundTaskLog(taskId, `Resolving target version: ${targetVersion}`);
|
appendBackgroundTaskLog(taskId, `Resolving target image: ${targetTag}${targetDigest ? ` @ ${targetDigest}` : ''}`);
|
||||||
appendBackgroundTaskLog(taskId, `Contacting deploy helper: ${config.helperBaseUrl}`);
|
appendBackgroundTaskLog(taskId, `Contacting deploy helper: ${config.helperBaseUrl}`);
|
||||||
|
|
||||||
const result = await streamUpdateCenterDeploy(
|
const result = await streamUpdateCenterDeploy(
|
||||||
@@ -190,7 +202,8 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
|||||||
config,
|
config,
|
||||||
helperToken: getUpdateCenterHelperToken(),
|
helperToken: getUpdateCenterHelperToken(),
|
||||||
source,
|
source,
|
||||||
targetVersion,
|
targetTag,
|
||||||
|
targetDigest,
|
||||||
},
|
},
|
||||||
(message) => {
|
(message) => {
|
||||||
appendBackgroundTaskLog(taskId, 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) => {
|
app.get<{ Params: { id: string } }>('/api/update-center/tasks/:id/stream', async (request, reply) => {
|
||||||
const taskId = String(request.params.id || '').trim();
|
const taskId = String(request.params.id || '').trim();
|
||||||
const task = getBackgroundTask(taskId);
|
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', () => {
|
describe('update center helper client', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -36,4 +40,98 @@ describe('update center helper client', () => {
|
|||||||
defaultDeploySource: 'github-release',
|
defaultDeploySource: 'github-release',
|
||||||
}, 'helper-token')).rejects.toThrow('deploy helper status timeout');
|
}, '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;
|
revision: string | null;
|
||||||
imageRepository: string | null;
|
imageRepository: string | null;
|
||||||
imageTag: string | null;
|
imageTag: string | null;
|
||||||
|
imageDigest: string | null;
|
||||||
healthy: boolean;
|
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;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,12 +30,20 @@ export type UpdateCenterDeploySummary = {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
targetSource: UpdateCenterVersionSource;
|
targetSource: UpdateCenterVersionSource;
|
||||||
targetTag: string;
|
targetTag: string;
|
||||||
|
targetDigest: string | null;
|
||||||
previousRevision: string | null;
|
previousRevision: string | null;
|
||||||
finalRevision: string | null;
|
finalRevision: string | null;
|
||||||
rolledBack: boolean;
|
rolledBack: boolean;
|
||||||
logLines: string[];
|
logLines: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateCenterRollbackSummary = {
|
||||||
|
success: boolean;
|
||||||
|
targetRevision: string;
|
||||||
|
finalRevision: string | null;
|
||||||
|
logLines: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type HelperDeployLogEvent = {
|
type HelperDeployLogEvent = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
@@ -118,7 +136,8 @@ export async function streamUpdateCenterDeploy(
|
|||||||
config: UpdateCenterConfig;
|
config: UpdateCenterConfig;
|
||||||
helperToken: string;
|
helperToken: string;
|
||||||
source: UpdateCenterVersionSource;
|
source: UpdateCenterVersionSource;
|
||||||
targetVersion: string;
|
targetTag: string;
|
||||||
|
targetDigest?: string | null;
|
||||||
},
|
},
|
||||||
onLog?: (message: string) => void,
|
onLog?: (message: string) => void,
|
||||||
): Promise<UpdateCenterDeploySummary> {
|
): Promise<UpdateCenterDeploySummary> {
|
||||||
@@ -134,7 +153,8 @@ export async function streamUpdateCenterDeploy(
|
|||||||
chartRef: input.config.chartRef,
|
chartRef: input.config.chartRef,
|
||||||
imageRepository: input.config.imageRepository,
|
imageRepository: input.config.imageRepository,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
targetVersion: input.targetVersion,
|
targetTag: input.targetTag,
|
||||||
|
targetDigest: input.targetDigest || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,6 +207,76 @@ export async function streamUpdateCenterDeploy(
|
|||||||
return finalResult;
|
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 = {
|
export const __updateCenterHelperClientTestUtils = {
|
||||||
encodeSseEvent,
|
encodeSseEvent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe('update center version service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('selectLatestDockerHubTag', () => {
|
describe('selectLatestDockerHubTag', () => {
|
||||||
it('ignores non-semver tags and prereleases', () => {
|
it('prefers release aliases ahead of semver tags', () => {
|
||||||
const latest = selectLatestDockerHubTag([
|
const latest = selectLatestDockerHubTag([
|
||||||
'latest',
|
'latest',
|
||||||
'main',
|
'main',
|
||||||
@@ -125,8 +125,8 @@ describe('update center version service', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(latest).toMatchObject({
|
expect(latest).toMatchObject({
|
||||||
rawVersion: '1.10.0',
|
rawVersion: 'latest',
|
||||||
normalizedVersion: '1.10.0',
|
normalizedVersion: 'latest',
|
||||||
source: 'docker-hub-tag',
|
source: 'docker-hub-tag',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -200,13 +200,27 @@ describe('update center version service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchLatestDockerHubTag', () => {
|
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({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
results: [
|
results: [
|
||||||
{ name: 'latest' },
|
{
|
||||||
{ name: 'v1.2.3-rc.1' },
|
name: 'latest',
|
||||||
{ name: 'v1.2.3' },
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
{ name: '1.10.0' },
|
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,
|
status: 200,
|
||||||
@@ -216,9 +230,37 @@ describe('update center version service', () => {
|
|||||||
const latest = await fetchLatestDockerHubTag();
|
const latest = await fetchLatestDockerHubTag();
|
||||||
expect(latest).toMatchObject({
|
expect(latest).toMatchObject({
|
||||||
source: 'docker-hub-tag',
|
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');
|
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;
|
rawVersion: string;
|
||||||
normalizedVersion: string;
|
normalizedVersion: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
tagName?: string | null;
|
||||||
|
digest?: string | null;
|
||||||
|
displayVersion?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubReleaseRecord = {
|
export type GitHubReleaseRecord = {
|
||||||
@@ -28,10 +32,18 @@ export type GitHubReleaseRecord = {
|
|||||||
name?: string | null;
|
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 STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
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 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 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> {
|
async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise<unknown> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -106,28 +118,80 @@ export function selectLatestStableGitHubRelease(
|
|||||||
rawVersion: selected.release.tag_name || selected.semver.raw,
|
rawVersion: selected.release.tag_name || selected.semver.raw,
|
||||||
normalizedVersion: selected.semver.normalized,
|
normalizedVersion: selected.semver.normalized,
|
||||||
url: selected.release.html_url || null,
|
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 {
|
function normalizeDockerHubTagRecord(input: string | DockerHubTagRecord): DockerHubTagRecord {
|
||||||
let selected: StableSemVer | null = null;
|
if (typeof input === 'string') {
|
||||||
|
return {
|
||||||
|
name: input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
for (const tag of tags) {
|
function normalizeDockerDigest(input: string | null | undefined): string | null {
|
||||||
const semver = parseStableSemVer(tag);
|
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 (!semver) continue;
|
||||||
if (!selected || compareStableSemVer(semver, selected) > 0) {
|
if (!selected || compareStableSemVer(semver, selected.semver) > 0) {
|
||||||
selected = semver;
|
selected = { record, semver };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) return null;
|
if (!selected) return null;
|
||||||
|
|
||||||
return {
|
return buildDockerHubVersionCandidate(selected.record, selected.semver.normalized);
|
||||||
source: 'docker-hub-tag',
|
|
||||||
rawVersion: selected.raw,
|
|
||||||
normalizedVersion: selected.normalized,
|
|
||||||
url: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePreferredDeploySource(input: {
|
export function resolvePreferredDeploySource(input: {
|
||||||
@@ -157,11 +221,8 @@ export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCand
|
|||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
'user-agent': 'metapi-update-center/1.0',
|
'user-agent': 'metapi-update-center/1.0',
|
||||||
},
|
},
|
||||||
}, 'Docker Hub tag lookup') as { results?: Array<{ name?: string | null }> };
|
}, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] };
|
||||||
const tags = Array.isArray(payload?.results)
|
return selectLatestDockerHubTag(Array.isArray(payload?.results) ? payload.results : []);
|
||||||
? payload.results.map((item) => String(item?.name || '')).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
return selectLatestDockerHubTag(tags);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentRuntimeVersion(): string {
|
export function getCurrentRuntimeVersion(): string {
|
||||||
|
|||||||
@@ -49,8 +49,20 @@ describe('update helper app', () => {
|
|||||||
namespace: 'ai',
|
namespace: 'ai',
|
||||||
revision: '17',
|
revision: '17',
|
||||||
imageRepository: '1467078763/metapi',
|
imageRepository: '1467078763/metapi',
|
||||||
imageTag: '1.3.1',
|
imageTag: 'latest',
|
||||||
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
healthy: true,
|
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({
|
const app = await buildUpdateHelperApp({
|
||||||
@@ -73,6 +85,7 @@ describe('update helper app', () => {
|
|||||||
healthy: true,
|
healthy: true,
|
||||||
releaseName: 'metapi',
|
releaseName: 'metapi',
|
||||||
namespace: 'ai',
|
namespace: 'ai',
|
||||||
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
});
|
});
|
||||||
expect(getStatus).toHaveBeenCalledWith({
|
expect(getStatus).toHaveBeenCalledWith({
|
||||||
namespace: 'ai',
|
namespace: 'ai',
|
||||||
@@ -88,6 +101,7 @@ describe('update helper app', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
targetSource: input.targetSource,
|
targetSource: input.targetSource,
|
||||||
targetTag: input.targetTag,
|
targetTag: input.targetTag,
|
||||||
|
targetDigest: input.targetDigest,
|
||||||
previousRevision: '17',
|
previousRevision: '17',
|
||||||
finalRevision: '18',
|
finalRevision: '18',
|
||||||
rolledBack: false,
|
rolledBack: false,
|
||||||
@@ -115,7 +129,8 @@ describe('update helper app', () => {
|
|||||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||||
imageRepository: '1467078763/metapi',
|
imageRepository: '1467078763/metapi',
|
||||||
source: 'github-release',
|
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('event: log');
|
||||||
expect(response.body).toContain('Running helm upgrade');
|
expect(response.body).toContain('Running helm upgrade');
|
||||||
expect(response.body).toContain('event: result');
|
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 {
|
import {
|
||||||
executeUpdateHelperDeploy,
|
executeUpdateHelperDeploy,
|
||||||
|
executeUpdateHelperRollback,
|
||||||
getUpdateHelperStatus,
|
getUpdateHelperStatus,
|
||||||
type UpdateHelperDeployInput,
|
type UpdateHelperDeployInput,
|
||||||
type UpdateHelperDeploySummary,
|
type UpdateHelperDeploySummary,
|
||||||
|
type UpdateHelperRollbackInput,
|
||||||
|
type UpdateHelperRollbackSummary,
|
||||||
type UpdateHelperStatus,
|
type UpdateHelperStatus,
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|
||||||
@@ -15,6 +18,10 @@ type BuildUpdateHelperAppOptions = {
|
|||||||
input: UpdateHelperDeployInput,
|
input: UpdateHelperDeployInput,
|
||||||
onLog?: (message: string) => void,
|
onLog?: (message: string) => void,
|
||||||
) => Promise<UpdateHelperDeploySummary>;
|
) => Promise<UpdateHelperDeploySummary>;
|
||||||
|
rollback?: (
|
||||||
|
input: UpdateHelperRollbackInput,
|
||||||
|
onLog?: (message: string) => void,
|
||||||
|
) => Promise<UpdateHelperRollbackSummary>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) {
|
function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) {
|
||||||
@@ -34,7 +41,16 @@ function toDeployInput(body: Record<string, unknown>): UpdateHelperDeployInput {
|
|||||||
chartRef: String(body.chartRef || '').trim(),
|
chartRef: String(body.chartRef || '').trim(),
|
||||||
imageRepository: String(body.imageRepository || '').trim(),
|
imageRepository: String(body.imageRepository || '').trim(),
|
||||||
targetSource: body.source === 'docker-hub-tag' ? 'docker-hub-tag' : 'github-release',
|
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 app = Fastify();
|
||||||
const getStatus = options.getStatus || (async (input) => await getUpdateHelperStatus(input));
|
const getStatus = options.getStatus || (async (input) => await getUpdateHelperStatus(input));
|
||||||
const deploy = options.deploy || (async (input, onLog) => await executeUpdateHelperDeploy(input, undefined, onLog));
|
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) => {
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
const path = String(request.raw.url || '').split('?')[0];
|
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) {
|
if (!input.namespace || !input.releaseName || !input.chartRef || !input.imageRepository || !input.targetTag) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
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();
|
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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
executeUpdateHelperDeploy,
|
executeUpdateHelperDeploy,
|
||||||
|
executeUpdateHelperRollback,
|
||||||
getUpdateHelperStatus,
|
getUpdateHelperStatus,
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ describe('update helper service', () => {
|
|||||||
imageRepository: '1467078763/metapi',
|
imageRepository: '1467078763/metapi',
|
||||||
targetSource: 'github-release',
|
targetSource: 'github-release',
|
||||||
targetTag: '1.3.0',
|
targetTag: '1.3.0',
|
||||||
|
targetDigest: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
runCommand: async ({ command, args }) => {
|
runCommand: async ({ command, args }) => {
|
||||||
@@ -55,6 +57,8 @@ describe('update helper service', () => {
|
|||||||
'image.repository=1467078763/metapi',
|
'image.repository=1467078763/metapi',
|
||||||
'--set',
|
'--set',
|
||||||
'image.tag=1.3.0',
|
'image.tag=1.3.0',
|
||||||
|
'--set-string',
|
||||||
|
'image.digest=',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,12 +83,69 @@ describe('update helper service', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
targetSource: 'github-release',
|
targetSource: 'github-release',
|
||||||
targetTag: '1.3.0',
|
targetTag: '1.3.0',
|
||||||
|
targetDigest: null,
|
||||||
previousRevision: '12',
|
previousRevision: '12',
|
||||||
finalRevision: '13',
|
finalRevision: '13',
|
||||||
rolledBack: false,
|
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 () => {
|
it('rolls back to the previous revision when rollout verification fails', async () => {
|
||||||
const calls: Array<{ command: string; args: string[] }> = [];
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
|
||||||
@@ -96,6 +157,7 @@ describe('update helper service', () => {
|
|||||||
imageRepository: '1467078763/metapi',
|
imageRepository: '1467078763/metapi',
|
||||||
targetSource: 'docker-hub-tag',
|
targetSource: 'docker-hub-tag',
|
||||||
targetTag: '1.3.1',
|
targetTag: '1.3.1',
|
||||||
|
targetDigest: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
runCommand: async ({ command, args }) => {
|
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(
|
const status = await getUpdateHelperStatus(
|
||||||
{
|
{
|
||||||
namespace: 'ai',
|
namespace: 'ai',
|
||||||
@@ -140,15 +256,65 @@ describe('update helper service', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (command === 'helm' && args[0] === 'get') {
|
if (command === 'helm' && args[0] === 'get') {
|
||||||
|
const revision = args.includes('--revision')
|
||||||
|
? args[args.indexOf('--revision') + 1]
|
||||||
|
: null;
|
||||||
|
if (revision === '16') {
|
||||||
return {
|
return {
|
||||||
stdout: JSON.stringify({
|
stdout: JSON.stringify({
|
||||||
image: {
|
image: {
|
||||||
repository: '1467078763/metapi',
|
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');
|
throw new Error('unexpected command');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -160,8 +326,29 @@ describe('update helper service', () => {
|
|||||||
namespace: 'ai',
|
namespace: 'ai',
|
||||||
revision: '17',
|
revision: '17',
|
||||||
imageRepository: '1467078763/metapi',
|
imageRepository: '1467078763/metapi',
|
||||||
imageTag: '1.3.1',
|
imageTag: 'latest',
|
||||||
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
healthy: true,
|
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;
|
imageRepository: string;
|
||||||
targetSource: UpdateCenterVersionSource;
|
targetSource: UpdateCenterVersionSource;
|
||||||
targetTag: string;
|
targetTag: string;
|
||||||
|
targetDigest?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateHelperRollbackInput = {
|
||||||
|
namespace: string;
|
||||||
|
releaseName: string;
|
||||||
|
targetRevision: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateHelperStatusInput = {
|
export type UpdateHelperStatusInput = {
|
||||||
@@ -33,12 +40,30 @@ export type UpdateHelperDeploySummary = {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
targetSource: UpdateCenterVersionSource;
|
targetSource: UpdateCenterVersionSource;
|
||||||
targetTag: string;
|
targetTag: string;
|
||||||
|
targetDigest: string | null;
|
||||||
previousRevision: string | null;
|
previousRevision: string | null;
|
||||||
finalRevision: string | null;
|
finalRevision: string | null;
|
||||||
rolledBack: boolean;
|
rolledBack: boolean;
|
||||||
logLines: string[];
|
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 = {
|
export type UpdateHelperStatus = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
@@ -46,7 +71,15 @@ export type UpdateHelperStatus = {
|
|||||||
revision: string | null;
|
revision: string | null;
|
||||||
imageRepository: string | null;
|
imageRepository: string | null;
|
||||||
imageTag: string | null;
|
imageTag: string | null;
|
||||||
|
imageDigest: string | null;
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
|
history: UpdateHelperHistoryEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedReleaseValues = {
|
||||||
|
imageRepository: string | null;
|
||||||
|
imageTag: string | null;
|
||||||
|
imageDigest: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
|
async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
|
||||||
@@ -94,9 +127,13 @@ async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
|
|||||||
function parseLatestRevision(stdout: string): string | null {
|
function parseLatestRevision(stdout: string): string | null {
|
||||||
try {
|
try {
|
||||||
const rows = JSON.parse(stdout) as Array<{ revision?: string | number }>;
|
const rows = JSON.parse(stdout) as Array<{ revision?: string | number }>;
|
||||||
const last = Array.isArray(rows) && rows.length > 0 ? rows[rows.length - 1] : null;
|
const revisions = rows
|
||||||
if (!last?.revision) return null;
|
.map((row) => String(row?.revision || '').trim())
|
||||||
return String(last.revision);
|
.filter(Boolean)
|
||||||
|
.map((value) => Number.parseInt(value, 10))
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
if (revisions.length <= 0) return null;
|
||||||
|
return String(Math.max(...revisions));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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(
|
export async function executeUpdateHelperDeploy(
|
||||||
input: UpdateHelperDeployInput,
|
input: UpdateHelperDeployInput,
|
||||||
deps: {
|
deps: {
|
||||||
@@ -134,34 +278,14 @@ export async function executeUpdateHelperDeploy(
|
|||||||
|
|
||||||
await commandRunner({
|
await commandRunner({
|
||||||
command: 'helm',
|
command: 'helm',
|
||||||
args: [
|
args: buildHelmUpgradeArgs(input),
|
||||||
'upgrade',
|
|
||||||
input.releaseName,
|
|
||||||
input.chartRef,
|
|
||||||
'--namespace',
|
|
||||||
input.namespace,
|
|
||||||
'--reuse-values',
|
|
||||||
'--set',
|
|
||||||
`image.repository=${input.imageRepository}`,
|
|
||||||
'--set',
|
|
||||||
`image.tag=${input.targetTag}`,
|
|
||||||
],
|
|
||||||
onLine: captureLine,
|
onLine: captureLine,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await commandRunner({
|
await commandRunner({
|
||||||
command: 'kubectl',
|
command: 'kubectl',
|
||||||
args: [
|
args: buildRolloutStatusArgs(input),
|
||||||
'rollout',
|
|
||||||
'status',
|
|
||||||
'deployment',
|
|
||||||
'-n',
|
|
||||||
input.namespace,
|
|
||||||
'-l',
|
|
||||||
`app.kubernetes.io/instance=${input.releaseName}`,
|
|
||||||
'--timeout=300s',
|
|
||||||
],
|
|
||||||
onLine: captureLine,
|
onLine: captureLine,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -184,6 +308,7 @@ export async function executeUpdateHelperDeploy(
|
|||||||
success: true,
|
success: true,
|
||||||
targetSource: input.targetSource,
|
targetSource: input.targetSource,
|
||||||
targetTag: input.targetTag,
|
targetTag: input.targetTag,
|
||||||
|
targetDigest: input.targetDigest || null,
|
||||||
previousRevision,
|
previousRevision,
|
||||||
finalRevision: parseStatusRevision(status.stdout),
|
finalRevision: parseStatusRevision(status.stdout),
|
||||||
rolledBack: false,
|
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(
|
export async function getUpdateHelperStatus(
|
||||||
input: UpdateHelperStatusInput,
|
input: UpdateHelperStatusInput,
|
||||||
deps: {
|
deps: {
|
||||||
@@ -198,14 +362,24 @@ export async function getUpdateHelperStatus(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<UpdateHelperStatus> {
|
): Promise<UpdateHelperStatus> {
|
||||||
const commandRunner = deps.runCommand || runCommand;
|
const commandRunner = deps.runCommand || runCommand;
|
||||||
const status = await commandRunner({
|
const [status, values, history, pods] = await Promise.all([
|
||||||
|
commandRunner({
|
||||||
command: 'helm',
|
command: 'helm',
|
||||||
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||||
});
|
}),
|
||||||
const values = await commandRunner({
|
commandRunner({
|
||||||
command: 'helm',
|
command: 'helm',
|
||||||
args: ['get', 'values', input.releaseName, '-n', input.namespace, '--all', '-o', 'json'],
|
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 {
|
const statusPayload = JSON.parse(status.stdout || '{}') as {
|
||||||
version?: string | number;
|
version?: string | number;
|
||||||
@@ -213,20 +387,47 @@ export async function getUpdateHelperStatus(
|
|||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const valuesPayload = JSON.parse(values.stdout || '{}') as {
|
const currentValues = parseReleaseValues(values.stdout);
|
||||||
image?: {
|
const runtimeDigest = parseRuntimeImageDigest(pods.stdout);
|
||||||
repository?: string;
|
const historyRows = parseHistoryRows(history.stdout).slice(0, 5);
|
||||||
tag?: string;
|
|
||||||
};
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
releaseName: input.releaseName,
|
releaseName: input.releaseName,
|
||||||
namespace: input.namespace,
|
namespace: input.namespace,
|
||||||
revision: statusPayload.version === undefined || statusPayload.version === null ? null : String(statusPayload.version),
|
revision: statusPayload.version === undefined || statusPayload.version === null ? null : String(statusPayload.version),
|
||||||
imageRepository: valuesPayload.image?.repository || null,
|
imageRepository: currentValues.imageRepository,
|
||||||
imageTag: valuesPayload.image?.tag || null,
|
imageTag: currentValues.imageTag,
|
||||||
|
imageDigest: runtimeDigest || currentValues.imageDigest,
|
||||||
healthy: String(statusPayload.info?.status || '').toLowerCase() === 'deployed',
|
healthy: String(statusPayload.info?.status || '').toLowerCase() === 'deployed',
|
||||||
|
history: historyEntries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -760,7 +760,15 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ export default function About() {
|
|||||||
try {
|
try {
|
||||||
const status = await api.getUpdateCenterStatus() as {
|
const status = await api.getUpdateCenterStatus() as {
|
||||||
currentVersion?: string;
|
currentVersion?: string;
|
||||||
githubRelease?: { normalizedVersion?: string } | null;
|
githubRelease?: { normalizedVersion?: string; displayVersion?: string } | null;
|
||||||
dockerHubTag?: { normalizedVersion?: string } | null;
|
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string } | null;
|
||||||
};
|
};
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (status.currentVersion) {
|
if (status.currentVersion) {
|
||||||
setCurrentVersion(`v${status.currentVersion}`);
|
setCurrentVersion(`v${status.currentVersion}`);
|
||||||
}
|
}
|
||||||
setLatestGitHubVersion(String(status.githubRelease?.normalizedVersion || ''));
|
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
|
||||||
setLatestDockerHubVersion(String(status.dockerHubTag?.normalizedVersion || ''));
|
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore update-center lookup failures on about page
|
// ignore update-center lookup failures on about page
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ describe('About update center', () => {
|
|||||||
currentVersion: '1.2.3',
|
currentVersion: '1.2.3',
|
||||||
githubRelease: {
|
githubRelease: {
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
|
displayVersion: '1.3.0',
|
||||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
||||||
},
|
},
|
||||||
dockerHubTag: {
|
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('GitHub 稳定版');
|
||||||
expect(text).toContain('1.3.0');
|
expect(text).toContain('1.3.0');
|
||||||
expect(text).toContain('Docker Hub');
|
expect(text).toContain('Docker Hub');
|
||||||
expect(text).toContain('1.3.1');
|
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||||
expect(text).toContain('前往更新中心');
|
expect(text).toContain('前往更新中心');
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
saveUpdateCenterConfig: vi.fn(),
|
saveUpdateCenterConfig: vi.fn(),
|
||||||
checkUpdateCenter: vi.fn(),
|
checkUpdateCenter: vi.fn(),
|
||||||
deployUpdateCenter: vi.fn(),
|
deployUpdateCenter: vi.fn(),
|
||||||
|
rollbackUpdateCenter: vi.fn(),
|
||||||
streamUpdateCenterTaskLogs: vi.fn(),
|
streamUpdateCenterTaskLogs: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -57,13 +58,31 @@ describe('UpdateCenterSection', () => {
|
|||||||
},
|
},
|
||||||
githubRelease: {
|
githubRelease: {
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
|
displayVersion: '1.3.0',
|
||||||
},
|
},
|
||||||
dockerHubTag: {
|
dockerHubTag: {
|
||||||
normalizedVersion: '1.3.1',
|
normalizedVersion: 'latest',
|
||||||
|
tagName: 'latest',
|
||||||
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
|
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
healthy: 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,
|
runningTask: null,
|
||||||
lastFinishedTask: null,
|
lastFinishedTask: null,
|
||||||
@@ -85,9 +104,14 @@ describe('UpdateCenterSection', () => {
|
|||||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
apiMock.checkUpdateCenter.mockResolvedValue({
|
||||||
githubRelease: {
|
githubRelease: {
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
|
displayVersion: '1.3.0',
|
||||||
},
|
},
|
||||||
dockerHubTag: {
|
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({
|
apiMock.deployUpdateCenter.mockResolvedValue({
|
||||||
@@ -97,6 +121,13 @@ describe('UpdateCenterSection', () => {
|
|||||||
id: 'task-1',
|
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 }) => {
|
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: 'Running helm upgrade' });
|
||||||
handlers.onLog?.({ message: 'Waiting for rollout' });
|
handlers.onLog?.({ message: 'Waiting for rollout' });
|
||||||
@@ -190,11 +221,13 @@ describe('UpdateCenterSection', () => {
|
|||||||
|
|
||||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||||
source: 'github-release',
|
source: 'github-release',
|
||||||
targetVersion: '1.3.0',
|
targetTag: '1.3.0',
|
||||||
|
targetDigest: null,
|
||||||
});
|
});
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||||
|
|
||||||
const text = collectText(root.root);
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||||
expect(text).toContain('Running helm upgrade');
|
expect(text).toContain('Running helm upgrade');
|
||||||
expect(text).toContain('Waiting for rollout');
|
expect(text).toContain('Waiting for rollout');
|
||||||
expect(text).toContain('任务状态 · 已完成');
|
expect(text).toContain('任务状态 · 已完成');
|
||||||
@@ -221,7 +254,8 @@ describe('UpdateCenterSection', () => {
|
|||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
},
|
},
|
||||||
dockerHubTag: {
|
dockerHubTag: {
|
||||||
normalizedVersion: '1.3.1',
|
normalizedVersion: 'latest',
|
||||||
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
},
|
},
|
||||||
helper: {
|
helper: {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -271,4 +305,42 @@ describe('UpdateCenterSection', () => {
|
|||||||
root?.unmount();
|
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?: {
|
githubRelease?: {
|
||||||
normalizedVersion?: string;
|
normalizedVersion?: string;
|
||||||
|
displayVersion?: string;
|
||||||
|
tagName?: string;
|
||||||
|
digest?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
dockerHubTag?: {
|
dockerHubTag?: {
|
||||||
normalizedVersion?: string;
|
normalizedVersion?: string;
|
||||||
|
displayVersion?: string;
|
||||||
|
tagName?: string;
|
||||||
|
digest?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
helper?: {
|
helper?: {
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
healthy?: boolean;
|
healthy?: boolean;
|
||||||
error?: string | null;
|
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;
|
} | null;
|
||||||
runningTask?: {
|
runningTask?: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -158,6 +178,23 @@ function getSourceBadge(enabled: boolean, version?: string) {
|
|||||||
return { className: 'badge badge-warning', label: '未发现版本' };
|
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() {
|
export default function UpdateCenterSection() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -223,24 +260,9 @@ export default function UpdateCenterSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const runDeploy = async (source: 'github-release' | 'docker-hub-tag', targetVersion: string) => {
|
const streamTaskLogs = async (taskId: 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.streamUpdateCenterTaskLogs(taskId, {
|
await api.streamUpdateCenterTaskLogs(taskId, {
|
||||||
signal: streamAbortRef.current.signal,
|
signal: streamAbortRef.current?.signal,
|
||||||
onLog: (entry) => {
|
onLog: (entry) => {
|
||||||
const message = String(entry?.message || '').trim();
|
const message = String(entry?.message || '').trim();
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
@@ -250,15 +272,47 @@ export default function UpdateCenterSection() {
|
|||||||
setTaskStatus(String(payload?.status || 'unknown'));
|
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 taskResponse = await api.getTask(taskId) as { task?: { status?: string; logs?: Array<{ message?: string }> } };
|
||||||
const task = taskResponse.task;
|
const task = taskResponse.task;
|
||||||
if (task) {
|
if (!task) return false;
|
||||||
setTaskStatus(String(task.status || 'unknown'));
|
setTaskStatus(String(task.status || 'unknown'));
|
||||||
setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []);
|
setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []);
|
||||||
toast.info('实时日志流已断开,已回退到任务详情快照');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 helperHealthy = !!status?.helper?.healthy;
|
||||||
const helperBadge = getHelperBadge(status?.helper, config.helperBaseUrl);
|
const helperBadge = getHelperBadge(status?.helper, config.helperBaseUrl);
|
||||||
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
|
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
|
||||||
@@ -530,7 +619,7 @@ export default function UpdateCenterSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>优先使用仓库稳定版 release,适合保留语义化版本节奏。</div>
|
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>优先使用仓库稳定版 release,适合保留语义化版本节奏。</div>
|
||||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||||
{status?.githubRelease?.normalizedVersion || '未发现'}
|
{status?.githubRelease?.displayVersion || status?.githubRelease?.normalizedVersion || '未发现'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||||
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
|
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
|
||||||
@@ -539,7 +628,10 @@ export default function UpdateCenterSection() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!helperHealthy) return;
|
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}
|
disabled={!canDeployGithub}
|
||||||
className={config.defaultDeploySource === 'github-release' ? 'btn btn-primary' : 'btn btn-ghost'}
|
className={config.defaultDeploySource === 'github-release' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||||
@@ -563,16 +655,22 @@ export default function UpdateCenterSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</div>
|
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</div>
|
||||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||||
{status?.dockerHubTag?.normalizedVersion || '未发现'}
|
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||||
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
|
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||||
|
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!helperHealthy) return;
|
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}
|
disabled={!canDeployDocker}
|
||||||
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
|
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||||
@@ -599,6 +697,9 @@ export default function UpdateCenterSection() {
|
|||||||
<div style={fieldHintStyle}>
|
<div style={fieldHintStyle}>
|
||||||
{status?.helper?.error || 'Helper 正常时会先执行 helm upgrade,再等待 kubectl rollout status。'}
|
{status?.helper?.error || 'Helper 正常时会先执行 helm upgrade,再等待 kubectl rollout status。'}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ ...fieldHintStyle, marginTop: 6 }}>
|
||||||
|
当前镜像:{formatImageTarget(status?.helper?.imageTag, status?.helper?.imageDigest) || '等待 helper 返回运行中镜像'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={sectionPanelStyle}>
|
<div style={sectionPanelStyle}>
|
||||||
@@ -626,6 +727,76 @@ export default function UpdateCenterSection() {
|
|||||||
</div>
|
</div>
|
||||||
</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={sectionPanelStyle}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>部署日志</div>
|
<div style={{ fontWeight: 600, fontSize: 13 }}>部署日志</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user