diff --git a/docs/k3s-update-center.md b/docs/k3s-update-center.md index 8257ad1..9f5124c 100644 --- a/docs/k3s-update-center.md +++ b/docs/k3s-update-center.md @@ -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=` 标签 - 主 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 缺其中任何一项,都只能看,不能真正部署。 diff --git a/src/server/routes/api/updateCenter.test.ts b/src/server/routes/api/updateCenter.test.ts index ba20e0c..a729982 100644 --- a/src/server/routes/api/updateCenter.test.ts +++ b/src/server/routes/api/updateCenter.test.ts @@ -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, diff --git a/src/server/routes/api/updateCenter.ts b/src/server/routes/api/updateCenter.ts index 8104e5e..831ebb7 100644 --- a/src/server/routes/api/updateCenter.ts +++ b/src/server/routes/api/updateCenter.ts @@ -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); diff --git a/src/server/services/updateCenterHelperClient.test.ts b/src/server/services/updateCenterHelperClient.test.ts index 91b9fbe..1fbbb69 100644 --- a/src/server/services/updateCenterHelperClient.test.ts +++ b/src/server/services/updateCenterHelperClient.test.ts @@ -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', + }); + }); }); diff --git a/src/server/services/updateCenterHelperClient.ts b/src/server/services/updateCenterHelperClient.ts index b79f946..e9c1067 100644 --- a/src/server/services/updateCenterHelperClient.ts +++ b/src/server/services/updateCenterHelperClient.ts @@ -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 { @@ -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 { + 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, }; diff --git a/src/server/services/updateCenterVersionService.test.ts b/src/server/services/updateCenterVersionService.test.ts index dd71faf..bd7e11a 100644 --- a/src/server/services/updateCenterVersionService.test.ts +++ b/src/server/services/updateCenterVersionService.test.ts @@ -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', + }); + }); }); }); diff --git a/src/server/services/updateCenterVersionService.ts b/src/server/services/updateCenterVersionService.ts index 414c1b0..22e2186 100644 --- a/src/server/services/updateCenterVersionService.ts +++ b/src/server/services/updateCenterVersionService.ts @@ -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 { 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): 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 }; - 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 { diff --git a/src/server/update-helper/app.test.ts b/src/server/update-helper/app.test.ts index e16207a..5bdb500 100644 --- a/src/server/update-helper/app.test.ts +++ b/src/server/update-helper/app.test.ts @@ -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"'); }); }); diff --git a/src/server/update-helper/app.ts b/src/server/update-helper/app.ts index e3161eb..ba1229a 100644 --- a/src/server/update-helper/app.ts +++ b/src/server/update-helper/app.ts @@ -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; + rollback?: ( + input: UpdateHelperRollbackInput, + onLog?: (message: string) => void, + ) => Promise; }; function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) { @@ -34,7 +41,16 @@ function toDeployInput(body: Record): 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): 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 }>('/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; } diff --git a/src/server/update-helper/service.test.ts b/src/server/update-helper/service.test.ts index ed5ffcb..237628a 100644 --- a/src/server/update-helper/service.test.ts +++ b/src/server/update-helper/service.test.ts @@ -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', + }, + ], }); }); }); diff --git a/src/server/update-helper/service.ts b/src/server/update-helper/service.ts index 992aef2..2f3787e 100644 --- a/src/server/update-helper/service.ts +++ b/src/server/update-helper/service.ts @@ -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 { @@ -94,9 +127,13 @@ async function runCommand(input: RunCommandInput): Promise { 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 { + 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 { 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, }; } diff --git a/src/web/api.ts b/src/web/api.ts index e32672b..6384779 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -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), }), diff --git a/src/web/pages/About.tsx b/src/web/pages/About.tsx index 971ace3..1ff16e9 100644 --- a/src/web/pages/About.tsx +++ b/src/web/pages/About.tsx @@ -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 } diff --git a/src/web/pages/About.update-center.test.tsx b/src/web/pages/About.update-center.test.tsx index 861ce2c..c480a1c 100644 --- a/src/web/pages/About.update-center.test.tsx +++ b/src/web/pages/About.update-center.test.tsx @@ -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(); diff --git a/src/web/pages/settings/UpdateCenterSection.test.tsx b/src/web/pages/settings/UpdateCenterSection.test.tsx index 1d0bf16..580f9f3 100644 --- a/src/web/pages/settings/UpdateCenterSection.test.tsx +++ b/src/web/pages/settings/UpdateCenterSection.test.tsx @@ -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( + + + + + , + ); + }); + 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(); + } + }); }); diff --git a/src/web/pages/settings/UpdateCenterSection.tsx b/src/web/pages/settings/UpdateCenterSection.tsx index ea30f66..55989d2 100644 --- a/src/web/pages/settings/UpdateCenterSection.tsx +++ b/src/web/pages/settings/UpdateCenterSection.tsx @@ -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() {
优先使用仓库稳定版 release,适合保留语义化版本节奏。
- {status?.githubRelease?.normalizedVersion || '未发现'} + {status?.githubRelease?.displayVersion || status?.githubRelease?.normalizedVersion || '未发现'}
{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() {
适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。
- {status?.dockerHubTag?.normalizedVersion || '未发现'} + {status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
+
+ 最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)} +
+ + + ); + })} + + ) : ( +
+ Helper 还没有返回可回退的 revision 历史。至少成功部署过一次后,这里才会稳定显示历史记录。 +
+ )} + +
部署日志