Support digest-aware update center rollbacks (#318)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user