forked from boos/BoosAPI
Support digest-aware update center rollbacks (#318)
This commit is contained in:
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user