[codex] add K3s update center (#314)
* add k3s update center * fix update center status timeouts * Polish update center settings UI * Document k3s update center * Refocus update center docs for users * Clarify k3s migration path in docs
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildUpdateHelperApp } from './app.js';
|
||||
|
||||
describe('update helper app', () => {
|
||||
const apps: Array<Awaited<ReturnType<typeof buildUpdateHelperApp>>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
it('requires bearer auth for protected helper routes', async () => {
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/status?namespace=ai&releaseName=metapi',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('allows unauthenticated health checks', async () => {
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns helper status for authorized requests', async () => {
|
||||
const getStatus = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.3.1',
|
||||
healthy: true,
|
||||
});
|
||||
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus,
|
||||
deploy: vi.fn(),
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/status?namespace=ai&releaseName=metapi',
|
||||
headers: {
|
||||
authorization: 'Bearer helper-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
healthy: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
});
|
||||
expect(getStatus).toHaveBeenCalledWith({
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
});
|
||||
});
|
||||
|
||||
it('streams deploy logs and final result as SSE when requested', async () => {
|
||||
const deploy = vi.fn().mockImplementation(async (input, onLog) => {
|
||||
onLog?.('Running helm upgrade');
|
||||
onLog?.('Waiting for rollout');
|
||||
return {
|
||||
success: true,
|
||||
targetSource: input.targetSource,
|
||||
targetTag: input.targetTag,
|
||||
previousRevision: '17',
|
||||
finalRevision: '18',
|
||||
rolledBack: false,
|
||||
logLines: ['Running helm upgrade', 'Waiting for rollout'],
|
||||
};
|
||||
});
|
||||
|
||||
const app = await buildUpdateHelperApp({
|
||||
token: 'helper-token',
|
||||
getStatus: vi.fn(),
|
||||
deploy,
|
||||
});
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/deploy',
|
||||
headers: {
|
||||
authorization: 'Bearer helper-token',
|
||||
accept: 'text/event-stream',
|
||||
},
|
||||
payload: {
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
},
|
||||
});
|
||||
|
||||
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 upgrade');
|
||||
expect(response.body).toContain('event: result');
|
||||
expect(response.body).toContain('"targetTag":"1.3.0"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
import {
|
||||
executeUpdateHelperDeploy,
|
||||
getUpdateHelperStatus,
|
||||
type UpdateHelperDeployInput,
|
||||
type UpdateHelperDeploySummary,
|
||||
type UpdateHelperStatus,
|
||||
} from './service.js';
|
||||
|
||||
type BuildUpdateHelperAppOptions = {
|
||||
token: string;
|
||||
getStatus?: (input: { namespace: string; releaseName: string }) => Promise<UpdateHelperStatus>;
|
||||
deploy?: (
|
||||
input: UpdateHelperDeployInput,
|
||||
onLog?: (message: string) => void,
|
||||
) => Promise<UpdateHelperDeploySummary>;
|
||||
};
|
||||
|
||||
function requireBearerToken(requestAuthHeader: string | undefined, expectedToken: string) {
|
||||
const raw = String(requestAuthHeader || '').trim();
|
||||
if (!raw.startsWith('Bearer ')) return false;
|
||||
return raw.slice('Bearer '.length).trim() === expectedToken;
|
||||
}
|
||||
|
||||
function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: string) => void } }, event: string, data: unknown) {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function toDeployInput(body: Record<string, unknown>): UpdateHelperDeployInput {
|
||||
return {
|
||||
namespace: String(body.namespace || '').trim(),
|
||||
releaseName: String(body.releaseName || '').trim(),
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildUpdateHelperApp(options: BuildUpdateHelperAppOptions): Promise<FastifyInstance> {
|
||||
const app = Fastify();
|
||||
const getStatus = options.getStatus || (async (input) => await getUpdateHelperStatus(input));
|
||||
const deploy = options.deploy || (async (input, onLog) => await executeUpdateHelperDeploy(input, undefined, onLog));
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const path = String(request.raw.url || '').split('?')[0];
|
||||
if (path === '/health') {
|
||||
return;
|
||||
}
|
||||
if (!requireBearerToken(String(request.headers.authorization || ''), options.token)) {
|
||||
return reply.code(401).send({
|
||||
success: false,
|
||||
message: 'unauthorized',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
app.get<{ Querystring: { namespace?: string; releaseName?: string } }>('/status', async (request, reply) => {
|
||||
const namespace = String(request.query.namespace || '').trim();
|
||||
const releaseName = String(request.query.releaseName || '').trim();
|
||||
if (!namespace || !releaseName) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: 'namespace and releaseName are required',
|
||||
});
|
||||
}
|
||||
return await getStatus({ namespace, releaseName });
|
||||
});
|
||||
|
||||
app.post<{ Body: Record<string, unknown> }>('/deploy', async (request, reply) => {
|
||||
const input = toDeployInput(request.body || {});
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
const wantsSse = String(request.headers.accept || '').includes('text/event-stream');
|
||||
if (!wantsSse) {
|
||||
return await deploy(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 deploy(input, (message) => {
|
||||
writeSseEvent(reply, 'log', { message });
|
||||
});
|
||||
writeSseEvent(reply, 'result', result);
|
||||
reply.raw.end();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { buildUpdateHelperApp } from './app.js';
|
||||
|
||||
const port = Number.parseInt(process.env.DEPLOY_HELPER_PORT || '9850', 10) || 9850;
|
||||
const host = (process.env.DEPLOY_HELPER_HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||
const token = String(process.env.DEPLOY_HELPER_TOKEN || '').trim();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('DEPLOY_HELPER_TOKEN is required');
|
||||
}
|
||||
|
||||
const app = await buildUpdateHelperApp({ token });
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
app.log.info(`Deploy helper listening on http://${host}:${port}`);
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
executeUpdateHelperDeploy,
|
||||
getUpdateHelperStatus,
|
||||
} from './service.js';
|
||||
|
||||
describe('update helper service', () => {
|
||||
it('builds helm upgrade and rollout verification commands with the configured release metadata', 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: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'helm' && args[0] === 'history') {
|
||||
return {
|
||||
stdout: JSON.stringify([{ revision: '12' }]),
|
||||
};
|
||||
}
|
||||
if (command === 'helm' && args[0] === 'status') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
version: 13,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { stdout: '' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: 'helm',
|
||||
args: ['history', 'metapi', '-n', 'ai', '-o', 'json'],
|
||||
},
|
||||
{
|
||||
command: 'helm',
|
||||
args: [
|
||||
'upgrade',
|
||||
'metapi',
|
||||
'oci://ghcr.io/cita-777/charts/metapi',
|
||||
'--namespace',
|
||||
'ai',
|
||||
'--reuse-values',
|
||||
'--set',
|
||||
'image.repository=1467078763/metapi',
|
||||
'--set',
|
||||
'image.tag=1.3.0',
|
||||
],
|
||||
},
|
||||
{
|
||||
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,
|
||||
targetSource: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
previousRevision: '12',
|
||||
finalRevision: '13',
|
||||
rolledBack: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back to the previous revision when rollout verification fails', async () => {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
|
||||
await expect(() => executeUpdateHelperDeploy(
|
||||
{
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
targetSource: 'docker-hub-tag',
|
||||
targetTag: '1.3.1',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'helm' && args[0] === 'history') {
|
||||
return {
|
||||
stdout: JSON.stringify([{ revision: '22' }]),
|
||||
};
|
||||
}
|
||||
if (command === 'kubectl' && args[0] === 'rollout') {
|
||||
throw new Error('rollout status timed out');
|
||||
}
|
||||
return { stdout: '' };
|
||||
},
|
||||
},
|
||||
)).rejects.toThrow('rollout status timed out');
|
||||
|
||||
expect(calls).toEqual(expect.arrayContaining([
|
||||
{
|
||||
command: 'helm',
|
||||
args: ['rollback', 'metapi', '22', '-n', 'ai'],
|
||||
},
|
||||
]));
|
||||
});
|
||||
|
||||
it('parses helper status from helm status and values output', async () => {
|
||||
const status = await getUpdateHelperStatus(
|
||||
{
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
},
|
||||
{
|
||||
runCommand: async ({ command, args }) => {
|
||||
if (command === 'helm' && args[0] === 'status') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
version: 17,
|
||||
info: {
|
||||
status: 'deployed',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command === 'helm' && args[0] === 'get') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
image: {
|
||||
repository: '1467078763/metapi',
|
||||
tag: '1.3.1',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error('unexpected command');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(status).toEqual({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.3.1',
|
||||
healthy: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type { UpdateCenterVersionSource } from '../services/updateCenterVersionService.js';
|
||||
|
||||
type RunCommandInput = {
|
||||
command: string;
|
||||
args: string[];
|
||||
onLine?: (line: string) => void;
|
||||
};
|
||||
|
||||
type RunCommandResult = {
|
||||
stdout: string;
|
||||
};
|
||||
|
||||
type RunCommand = (input: RunCommandInput) => Promise<RunCommandResult>;
|
||||
|
||||
export type UpdateHelperDeployInput = {
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
chartRef: string;
|
||||
imageRepository: string;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
};
|
||||
|
||||
export type UpdateHelperStatusInput = {
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
};
|
||||
|
||||
export type UpdateHelperDeploySummary = {
|
||||
success: boolean;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
previousRevision: string | null;
|
||||
finalRevision: string | null;
|
||||
rolledBack: boolean;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
export type UpdateHelperStatus = {
|
||||
ok: boolean;
|
||||
releaseName: string;
|
||||
namespace: string;
|
||||
revision: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
healthy: boolean;
|
||||
};
|
||||
|
||||
async function runCommand(input: RunCommandInput): Promise<RunCommandResult> {
|
||||
return await new Promise<RunCommandResult>((resolve, reject) => {
|
||||
const child = spawn(input.command, input.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) return;
|
||||
input.onLine?.(normalized);
|
||||
};
|
||||
|
||||
if (child.stdout) {
|
||||
const stdoutReader = createInterface({ input: child.stdout });
|
||||
stdoutReader.on('line', (line) => {
|
||||
stdout += `${line}\n`;
|
||||
handleLine(line);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
const stderrReader = createInterface({ input: child.stderr });
|
||||
stderrReader.on('line', (line) => {
|
||||
stderr += `${line}\n`;
|
||||
handleLine(line);
|
||||
});
|
||||
}
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout: stdout.trim() });
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || stdout.trim() || `${input.command} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatusRevision(stdout: string): string | null {
|
||||
try {
|
||||
const payload = JSON.parse(stdout) as { version?: string | number };
|
||||
if (payload?.version === undefined || payload?.version === null) return null;
|
||||
return String(payload.version);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeUpdateHelperDeploy(
|
||||
input: UpdateHelperDeployInput,
|
||||
deps: {
|
||||
runCommand?: RunCommand;
|
||||
} = {},
|
||||
onLog?: (line: string) => void,
|
||||
): Promise<UpdateHelperDeploySummary> {
|
||||
const commandRunner = deps.runCommand || runCommand;
|
||||
const logLines: string[] = [];
|
||||
const captureLine = (line: string) => {
|
||||
logLines.push(line);
|
||||
onLog?.(line);
|
||||
};
|
||||
|
||||
const history = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['history', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
});
|
||||
const previousRevision = parseLatestRevision(history.stdout);
|
||||
|
||||
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}`,
|
||||
],
|
||||
onLine: captureLine,
|
||||
});
|
||||
|
||||
try {
|
||||
await commandRunner({
|
||||
command: 'kubectl',
|
||||
args: [
|
||||
'rollout',
|
||||
'status',
|
||||
'deployment',
|
||||
'-n',
|
||||
input.namespace,
|
||||
'-l',
|
||||
`app.kubernetes.io/instance=${input.releaseName}`,
|
||||
'--timeout=300s',
|
||||
],
|
||||
onLine: captureLine,
|
||||
});
|
||||
} catch (error) {
|
||||
if (previousRevision) {
|
||||
await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['rollback', input.releaseName, previousRevision, '-n', input.namespace],
|
||||
onLine: captureLine,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status = await commandRunner({
|
||||
command: 'helm',
|
||||
args: ['status', input.releaseName, '-n', input.namespace, '-o', 'json'],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetSource: input.targetSource,
|
||||
targetTag: input.targetTag,
|
||||
previousRevision,
|
||||
finalRevision: parseStatusRevision(status.stdout),
|
||||
rolledBack: false,
|
||||
logLines,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUpdateHelperStatus(
|
||||
input: UpdateHelperStatusInput,
|
||||
deps: {
|
||||
runCommand?: RunCommand;
|
||||
} = {},
|
||||
): 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 statusPayload = JSON.parse(status.stdout || '{}') as {
|
||||
version?: string | number;
|
||||
info?: {
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
const valuesPayload = JSON.parse(values.stdout || '{}') as {
|
||||
image?: {
|
||||
repository?: string;
|
||||
tag?: string;
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
healthy: String(statusPayload.info?.status || '').toLowerCase() === 'deployed',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user