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
+61 -3
View File
@@ -49,8 +49,20 @@ describe('update helper app', () => {
namespace: 'ai',
revision: '17',
imageRepository: '1467078763/metapi',
imageTag: '1.3.1',
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
healthy: true,
history: [
{
revision: '16',
updatedAt: '2026-03-28T12:00:00Z',
status: 'superseded',
description: 'Rollback to stable digest',
imageRepository: '1467078763/metapi',
imageTag: 'main',
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
],
});
const app = await buildUpdateHelperApp({
@@ -73,6 +85,7 @@ describe('update helper app', () => {
healthy: true,
releaseName: 'metapi',
namespace: 'ai',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
});
expect(getStatus).toHaveBeenCalledWith({
namespace: 'ai',
@@ -88,6 +101,7 @@ describe('update helper app', () => {
success: true,
targetSource: input.targetSource,
targetTag: input.targetTag,
targetDigest: input.targetDigest,
previousRevision: '17',
finalRevision: '18',
rolledBack: false,
@@ -115,7 +129,8 @@ describe('update helper app', () => {
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
imageRepository: '1467078763/metapi',
source: 'github-release',
targetVersion: '1.3.0',
targetTag: 'latest',
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
},
});
@@ -124,6 +139,49 @@ describe('update helper app', () => {
expect(response.body).toContain('event: log');
expect(response.body).toContain('Running helm upgrade');
expect(response.body).toContain('event: result');
expect(response.body).toContain('"targetTag":"1.3.0"');
expect(response.body).toContain('"targetTag":"latest"');
expect(response.body).toContain('"targetDigest":"sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35"');
});
it('streams rollback logs and final result as SSE when requested', async () => {
const rollback = vi.fn().mockImplementation(async (input, onLog) => {
onLog?.('Running helm rollback');
onLog?.('Waiting for rollout');
return {
success: true,
targetRevision: input.targetRevision,
finalRevision: '20',
logLines: ['Running helm rollback', 'Waiting for rollout'],
};
});
const app = await buildUpdateHelperApp({
token: 'helper-token',
getStatus: vi.fn(),
deploy: vi.fn(),
rollback,
});
apps.push(app);
const response = await app.inject({
method: 'POST',
url: '/rollback',
headers: {
authorization: 'Bearer helper-token',
accept: 'text/event-stream',
},
payload: {
namespace: 'ai',
releaseName: 'metapi',
targetRevision: '16',
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('event: log');
expect(response.body).toContain('Running helm rollback');
expect(response.body).toContain('event: result');
expect(response.body).toContain('"targetRevision":"16"');
});
});
+45 -2
View File
@@ -2,9 +2,12 @@ import Fastify, { type FastifyInstance } from 'fastify';
import {
executeUpdateHelperDeploy,
executeUpdateHelperRollback,
getUpdateHelperStatus,
type UpdateHelperDeployInput,
type UpdateHelperDeploySummary,
type UpdateHelperRollbackInput,
type UpdateHelperRollbackSummary,
type UpdateHelperStatus,
} from './service.js';
@@ -15,6 +18,10 @@ type BuildUpdateHelperAppOptions = {
input: UpdateHelperDeployInput,
onLog?: (message: string) => void,
) => Promise<UpdateHelperDeploySummary>;
rollback?: (
input: UpdateHelperRollbackInput,
onLog?: (message: string) => void,
) => Promise<UpdateHelperRollbackSummary>;
};
function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) {
@@ -34,7 +41,16 @@ function toDeployInput(body: Record<string, unknown>): UpdateHelperDeployInput {
chartRef: String(body.chartRef || '').trim(),
imageRepository: String(body.imageRepository || '').trim(),
targetSource: body.source === 'docker-hub-tag' ? 'docker-hub-tag' : 'github-release',
targetTag: String(body.targetVersion || '').trim(),
targetTag: String(body.targetTag || body.targetVersion || '').trim(),
targetDigest: String(body.targetDigest || '').trim() || null,
};
}
function toRollbackInput(body: Record<string, unknown>): UpdateHelperRollbackInput {
return {
namespace: String(body.namespace || '').trim(),
releaseName: String(body.releaseName || '').trim(),
targetRevision: String(body.targetRevision || '').trim(),
};
}
@@ -42,6 +58,7 @@ export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions)
const app = Fastify();
const getStatus = options.getStatus || (async (input) => await getUpdateHelperStatus(input));
const deploy = options.deploy || (async (input, onLog) => await executeUpdateHelperDeploy(input, undefined, onLog));
const rollback = options.rollback || (async (input, onLog) => await executeUpdateHelperRollback(input, undefined, onLog));
app.addHook('onRequest', async (request, reply) => {
const path = String(request.raw.url || '').split('?')[0];
@@ -75,7 +92,7 @@ export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions)
if (!input.namespace || !input.releaseName || !input.chartRef || !input.imageRepository || !input.targetTag) {
return reply.code(400).send({
success: false,
message: 'namespace, releaseName, chartRef, imageRepository, and targetVersion are required',
message: 'namespace, releaseName, chartRef, imageRepository, and targetTag are required',
});
}
@@ -96,5 +113,31 @@ export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions)
reply.raw.end();
});
app.post<{ Body: Record<string, unknown> }>('/rollback', async (request, reply) => {
const input = toRollbackInput(request.body || {});
if (!input.namespace || !input.releaseName || !input.targetRevision) {
return reply.code(400).send({
success: false,
message: 'namespace, releaseName, and targetRevision are required',
});
}
const wantsSse = String(request.headers.accept || '').includes('text/event-stream');
if (!wantsSse) {
return await rollback(input);
}
reply.hijack();
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
const result = await rollback(input, (message) => {
writeSseEvent(reply, 'log', { message });
});
writeSseEvent(reply, 'result', result);
reply.raw.end();
});
return app;
}
+190 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
executeUpdateHelperDeploy,
executeUpdateHelperRollback,
getUpdateHelperStatus,
} from './service.js';
@@ -16,6 +17,7 @@ describe('update helper service', () => {
imageRepository: '1467078763/metapi',
targetSource: 'github-release',
targetTag: '1.3.0',
targetDigest: null,
},
{
runCommand: async ({ command, args }) => {
@@ -55,6 +57,8 @@ describe('update helper service', () => {
'image.repository=1467078763/metapi',
'--set',
'image.tag=1.3.0',
'--set-string',
'image.digest=',
],
},
{
@@ -79,12 +83,69 @@ describe('update helper service', () => {
success: true,
targetSource: 'github-release',
targetTag: '1.3.0',
targetDigest: null,
previousRevision: '12',
finalRevision: '13',
rolledBack: false,
});
});
it('passes image digest overrides through helm when a digest-aware deployment is requested', async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const summary = await executeUpdateHelperDeploy(
{
namespace: 'ai',
releaseName: 'metapi',
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
imageRepository: '1467078763/metapi',
targetSource: 'docker-hub-tag',
targetTag: 'latest',
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
},
{
runCommand: async ({ command, args }) => {
calls.push({ command, args });
if (command === 'helm' && args[0] === 'history') {
return {
stdout: JSON.stringify([{ revision: '18' }]),
};
}
if (command === 'helm' && args[0] === 'status') {
return {
stdout: JSON.stringify({
version: 19,
}),
};
}
return { stdout: '' };
},
},
);
expect(calls).toContainEqual({
command: 'helm',
args: [
'upgrade',
'metapi',
'oci://ghcr.io/cita-777/charts/metapi',
'--namespace',
'ai',
'--reuse-values',
'--set',
'image.repository=1467078763/metapi',
'--set',
'image.tag=latest',
'--set-string',
'image.digest=sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
],
});
expect(summary).toMatchObject({
targetTag: 'latest',
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
finalRevision: '19',
});
});
it('rolls back to the previous revision when rollout verification fails', async () => {
const calls: Array<{ command: string; args: string[] }> = [];
@@ -96,6 +157,7 @@ describe('update helper service', () => {
imageRepository: '1467078763/metapi',
targetSource: 'docker-hub-tag',
targetTag: '1.3.1',
targetDigest: null,
},
{
runCommand: async ({ command, args }) => {
@@ -121,7 +183,61 @@ describe('update helper service', () => {
]));
});
it('parses helper status from helm status and values output', async () => {
it('runs an explicit rollback workflow for a chosen revision', async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const summary = await executeUpdateHelperRollback(
{
namespace: 'ai',
releaseName: 'metapi',
targetRevision: '16',
},
{
runCommand: async ({ command, args }) => {
calls.push({ command, args });
if (command === 'helm' && args[0] === 'status') {
return {
stdout: JSON.stringify({
version: 20,
}),
};
}
return { stdout: '' };
},
},
);
expect(calls).toEqual([
{
command: 'helm',
args: ['rollback', 'metapi', '16', '-n', 'ai'],
},
{
command: 'kubectl',
args: [
'rollout',
'status',
'deployment',
'-n',
'ai',
'-l',
'app.kubernetes.io/instance=metapi',
'--timeout=300s',
],
},
{
command: 'helm',
args: ['status', 'metapi', '-n', 'ai', '-o', 'json'],
},
]);
expect(summary).toMatchObject({
success: true,
targetRevision: '16',
finalRevision: '20',
});
});
it('parses helper status from helm status, runtime image ids, and recent helm history', async () => {
const status = await getUpdateHelperStatus(
{
namespace: 'ai',
@@ -140,15 +256,65 @@ describe('update helper service', () => {
};
}
if (command === 'helm' && args[0] === 'get') {
const revision = args.includes('--revision')
? args[args.indexOf('--revision') + 1]
: null;
if (revision === '16') {
return {
stdout: JSON.stringify({
image: {
repository: '1467078763/metapi',
tag: 'latest',
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
}),
};
}
if (revision === '17') {
return {
stdout: JSON.stringify({
image: {
repository: '1467078763/metapi',
tag: 'main',
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
}),
};
}
return {
stdout: JSON.stringify({
image: {
repository: '1467078763/metapi',
tag: '1.3.1',
tag: 'latest',
},
}),
};
}
if (command === 'helm' && args[0] === 'history') {
return {
stdout: JSON.stringify([
{ revision: '17', updated: '2026-03-29T12:00:00Z', status: 'deployed', description: 'Upgrade complete' },
{ revision: '16', updated: '2026-03-28T12:00:00Z', status: 'superseded', description: 'Rollback to stable digest' },
]),
};
}
if (command === 'kubectl' && args[0] === 'get') {
return {
stdout: JSON.stringify({
items: [
{
status: {
containerStatuses: [
{
imageID: 'docker-pullable://1467078763/metapi@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
},
],
},
},
],
}),
};
}
throw new Error('unexpected command');
},
},
@@ -160,8 +326,29 @@ describe('update helper service', () => {
namespace: 'ai',
revision: '17',
imageRepository: '1467078763/metapi',
imageTag: '1.3.1',
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
healthy: true,
history: [
{
revision: '17',
updatedAt: '2026-03-29T12:00:00Z',
status: 'deployed',
description: 'Upgrade complete',
imageRepository: '1467078763/metapi',
imageTag: 'main',
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
{
revision: '16',
updatedAt: '2026-03-28T12:00:00Z',
status: 'superseded',
description: 'Rollback to stable digest',
imageRepository: '1467078763/metapi',
imageTag: 'latest',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
],
});
});
});
+241 -40
View File
@@ -22,6 +22,13 @@ export type UpdateHelperDeployInput = {
imageRepository: string;
targetSource: UpdateCenterVersionSource;
targetTag: string;
targetDigest?: string | null;
};
export type UpdateHelperRollbackInput = {
namespace: string;
releaseName: string;
targetRevision: string;
};
export type UpdateHelperStatusInput = {
@@ -33,12 +40,30 @@ export type UpdateHelperDeploySummary = {
success: boolean;
targetSource: UpdateCenterVersionSource;
targetTag: string;
targetDigest: string | null;
previousRevision: string | null;
finalRevision: string | null;
rolledBack: boolean;
logLines: string[];
};
export type UpdateHelperRollbackSummary = {
success: boolean;
targetRevision: string;
finalRevision: string | null;
logLines: string[];
};
export type UpdateHelperHistoryEntry = {
revision: string;
updatedAt: string | null;
status: string | null;
description: string | null;
imageRepository: string | null;
imageTag: string | null;
imageDigest: string | null;
};
export type UpdateHelperStatus = {
ok: boolean;
releaseName: string;
@@ -46,7 +71,15 @@ export type UpdateHelperStatus = {
revision: string | null;
imageRepository: string | null;
imageTag: string | null;
imageDigest: string | null;
healthy: boolean;
history: UpdateHelperHistoryEntry[];
};
type ParsedReleaseValues = {
imageRepository: string | null;
imageTag: string | null;
imageDigest: string | null;
};
async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
@@ -94,9 +127,13 @@ async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
function parseLatestRevision(stdout: string): string | null {
try {
const rows = JSON.parse(stdout) as Array<{ revision?: string | number }>;
const last = Array.isArray(rows) && rows.length > 0 ? rows[rows.length - 1] : null;
if (!last?.revision) return null;
return String(last.revision);
const revisions = rows
.map((row) => String(row?.revision || '').trim())
.filter(Boolean)
.map((value) => Number.parseInt(value, 10))
.filter(Number.isFinite);
if (revisions.length <= 0) return null;
return String(Math.max(...revisions));
} catch {
return null;
}
@@ -112,6 +149,113 @@ function parseStatusRevision(stdout: string): string | null {
}
}
function parseReleaseValues(stdout: string): ParsedReleaseValues {
try {
const payload = JSON.parse(stdout || '{}') as {
image?: {
repository?: string;
tag?: string;
digest?: string;
};
};
return {
imageRepository: payload.image?.repository || null,
imageTag: payload.image?.tag || null,
imageDigest: payload.image?.digest || null,
};
} catch {
return {
imageRepository: null,
imageTag: null,
imageDigest: null,
};
}
}
function parseRuntimeImageDigest(stdout: string): string | null {
try {
const payload = JSON.parse(stdout || '{}') as {
items?: Array<{
status?: {
containerStatuses?: Array<{
imageID?: string | null;
image?: string | null;
}>;
};
}>;
};
for (const item of payload.items || []) {
for (const status of item.status?.containerStatuses || []) {
const candidate = `${status.imageID || ''} ${status.image || ''}`;
const match = candidate.match(/(sha256:[a-f0-9]{64})/i);
if (match?.[1]) {
return match[1].toLowerCase();
}
}
}
return null;
} catch {
return null;
}
}
function parseHistoryRows(stdout: string): Array<{
revision: string;
updatedAt: string | null;
status: string | null;
description: string | null;
}> {
try {
const rows = JSON.parse(stdout || '[]') as Array<{
revision?: string | number;
updated?: string | null;
status?: string | null;
description?: string | null;
}>;
return rows
.map((row) => ({
revision: String(row?.revision || '').trim(),
updatedAt: String(row?.updated || '').trim() || null,
status: String(row?.status || '').trim() || null,
description: String(row?.description || '').trim() || null,
}))
.filter((row) => row.revision);
} catch {
return [];
}
}
function buildRolloutStatusArgs(input: { namespace: string; releaseName: string }): string[] {
return [
'rollout',
'status',
'deployment',
'-n',
input.namespace,
'-l',
`app.kubernetes.io/instance=${input.releaseName}`,
'--timeout=300s',
];
}
function buildHelmUpgradeArgs(input: UpdateHelperDeployInput): string[] {
return [
'upgrade',
input.releaseName,
input.chartRef,
'--namespace',
input.namespace,
'--reuse-values',
'--set',
`image.repository=${input.imageRepository}`,
'--set',
`image.tag=${input.targetTag}`,
'--set-string',
`image.digest=${input.targetDigest || ''}`,
];
}
export async function executeUpdateHelperDeploy(
input: UpdateHelperDeployInput,
deps: {
@@ -134,34 +278,14 @@ export async function executeUpdateHelperDeploy(
await commandRunner({
command: 'helm',
args: [
'upgrade',
input.releaseName,
input.chartRef,
'--namespace',
input.namespace,
'--reuse-values',
'--set',
`image.repository=${input.imageRepository}`,
'--set',
`image.tag=${input.targetTag}`,
],
args: buildHelmUpgradeArgs(input),
onLine: captureLine,
});
try {
await commandRunner({
command: 'kubectl',
args: [
'rollout',
'status',
'deployment',
'-n',
input.namespace,
'-l',
`app.kubernetes.io/instance=${input.releaseName}`,
'--timeout=300s',
],
args: buildRolloutStatusArgs(input),
onLine: captureLine,
});
} catch (error) {
@@ -184,6 +308,7 @@ export async function executeUpdateHelperDeploy(
success: true,
targetSource: input.targetSource,
targetTag: input.targetTag,
targetDigest: input.targetDigest || null,
previousRevision,
finalRevision: parseStatusRevision(status.stdout),
rolledBack: false,
@@ -191,6 +316,45 @@ export async function executeUpdateHelperDeploy(
};
}
export async function executeUpdateHelperRollback(
input: UpdateHelperRollbackInput,
deps: {
runCommand?: RunCommand;
} = {},
onLog?: (line: string) => void,
): Promise<UpdateHelperRollbackSummary> {
const commandRunner = deps.runCommand || runCommand;
const logLines: string[] = [];
const captureLine = (line: string) => {
logLines.push(line);
onLog?.(line);
};
await commandRunner({
command: 'helm',
args: ['rollback', input.releaseName, input.targetRevision, '-n', input.namespace],
onLine: captureLine,
});
await commandRunner({
command: 'kubectl',
args: buildRolloutStatusArgs(input),
onLine: captureLine,
});
const status = await commandRunner({
command: 'helm',
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
});
return {
success: true,
targetRevision: input.targetRevision,
finalRevision: parseStatusRevision(status.stdout),
logLines,
};
}
export async function getUpdateHelperStatus(
input: UpdateHelperStatusInput,
deps: {
@@ -198,14 +362,24 @@ export async function getUpdateHelperStatus(
} = {},
): Promise<UpdateHelperStatus> {
const commandRunner = deps.runCommand || runCommand;
const status = await commandRunner({
command: 'helm',
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
});
const values = await commandRunner({
command: 'helm',
args: ['get', 'values', input.releaseName, '-n', input.namespace, '--all', '-o', 'json'],
});
const [status, values, history, pods] = await Promise.all([
commandRunner({
command: 'helm',
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
}),
commandRunner({
command: 'helm',
args: ['get', 'values', input.releaseName, '-n', input.namespace, '--all', '-o', 'json'],
}),
commandRunner({
command: 'helm',
args: ['history', input.releaseName, '-n', input.namespace, '-o', 'json'],
}),
commandRunner({
command: 'kubectl',
args: ['get', 'pods', '-n', input.namespace, '-l', `app.kubernetes.io/instance=${input.releaseName}`, '-o', 'json'],
}),
]);
const statusPayload = JSON.parse(status.stdout || '{}') as {
version?: string | number;
@@ -213,20 +387,47 @@ export async function getUpdateHelperStatus(
status?: string;
};
};
const valuesPayload = JSON.parse(values.stdout || '{}') as {
image?: {
repository?: string;
tag?: string;
const currentValues = parseReleaseValues(values.stdout);
const runtimeDigest = parseRuntimeImageDigest(pods.stdout);
const historyRows = parseHistoryRows(history.stdout).slice(0, 5);
const historyEntries: UpdateHelperHistoryEntry[] = [];
for (const row of historyRows) {
let releaseValues: ParsedReleaseValues = {
imageRepository: null,
imageTag: null,
imageDigest: null,
};
};
try {
const result = await commandRunner({
command: 'helm',
args: ['get', 'values', input.releaseName, '-n', input.namespace, '--revision', row.revision, '--all', '-o', 'json'],
});
releaseValues = parseReleaseValues(result.stdout);
} catch {
// Best effort: history should still render even if a revision payload cannot be read.
}
historyEntries.push({
revision: row.revision,
updatedAt: row.updatedAt,
status: row.status,
description: row.description,
imageRepository: releaseValues.imageRepository,
imageTag: releaseValues.imageTag,
imageDigest: releaseValues.imageDigest,
});
}
return {
ok: true,
releaseName: input.releaseName,
namespace: input.namespace,
revision: statusPayload.version === undefined || statusPayload.version === null ? null : String(statusPayload.version),
imageRepository: valuesPayload.image?.repository || null,
imageTag: valuesPayload.image?.tag || null,
imageRepository: currentValues.imageRepository,
imageTag: currentValues.imageTag,
imageDigest: runtimeDigest || currentValues.imageDigest,
healthy: String(statusPayload.info?.status || '').toLowerCase() === 'deployed',
history: historyEntries,
};
}