[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:
Cita
2026-03-29 17:43:49 +08:00
committed by GitHub
parent dcfd45ea65
commit 11583c51b9
30 changed files with 3819 additions and 7 deletions
+129
View File
@@ -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"');
});
});
+100
View File
@@ -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;
}
+19
View File
@@ -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);
}
+167
View File
@@ -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,
});
});
});
+232
View File
@@ -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',
};
}