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
@@ -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 {