Support digest-aware update center rollbacks (#318)

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