[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
+356
View File
@@ -0,0 +1,356 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
const {
fetchLatestStableGitHubReleaseMock,
fetchLatestDockerHubTagMock,
getUpdateCenterHelperStatusMock,
streamUpdateCenterDeployMock,
} = vi.hoisted(() => ({
fetchLatestStableGitHubReleaseMock: vi.fn(),
fetchLatestDockerHubTagMock: vi.fn(),
getUpdateCenterHelperStatusMock: vi.fn(),
streamUpdateCenterDeployMock: vi.fn(),
}));
vi.mock('../../services/updateCenterVersionService.js', async () => {
const actual = await vi.importActual<typeof import('../../services/updateCenterVersionService.js')>('../../services/updateCenterVersionService.js');
return {
...actual,
fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args),
fetchLatestDockerHubTag: (...args: unknown[]) => fetchLatestDockerHubTagMock(...args),
};
});
vi.mock('../../services/updateCenterHelperClient.js', () => ({
getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args),
streamUpdateCenterDeploy: (...args: unknown[]) => streamUpdateCenterDeployMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('update center routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let resetBackgroundTasks: (() => void) | null = null;
let getBackgroundTask: ((taskId: string) => { status: string; logs?: Array<{ message: string }> } | null) | null = null;
async function saveValidConfig() {
const response = await app.inject({
method: 'PUT',
url: '/api/update-center/config',
payload: {
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',
},
});
expect(response.statusCode).toBe(200);
}
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-update-center-'));
process.env.DATA_DIR = dataDir;
process.env.DEPLOY_HELPER_TOKEN = 'helper-token';
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./updateCenter.js');
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
db = dbModule.db;
schema = dbModule.schema;
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
app = Fastify();
await app.register(routesModule.updateCenterRoutes);
});
beforeEach(async () => {
fetchLatestStableGitHubReleaseMock.mockReset();
fetchLatestDockerHubTagMock.mockReset();
getUpdateCenterHelperStatusMock.mockReset();
streamUpdateCenterDeployMock.mockReset();
resetBackgroundTasks?.();
await db.delete(schema.events).run();
await db.delete(schema.settings).run();
});
afterAll(async () => {
if (app) {
await app.close();
}
delete process.env.DATA_DIR;
delete process.env.DEPLOY_HELPER_TOKEN;
});
it('persists config and returns status with both version channels and helper summary', async () => {
fetchLatestStableGitHubReleaseMock.mockResolvedValue({
source: 'github-release',
rawVersion: 'v1.3.0',
normalizedVersion: '1.3.0',
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
});
fetchLatestDockerHubTagMock.mockResolvedValue({
source: 'docker-hub-tag',
rawVersion: '1.3.1',
normalizedVersion: '1.3.1',
url: null,
});
getUpdateCenterHelperStatusMock.mockResolvedValue({
ok: true,
releaseName: 'metapi',
namespace: 'ai',
revision: '12',
imageRepository: '1467078763/metapi',
imageTag: '1.2.3',
healthy: true,
});
const saveResponse = await app.inject({
method: 'PUT',
url: '/api/update-center/config',
payload: {
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',
},
});
expect(saveResponse.statusCode).toBe(200);
const savedRow = await db.select().from(schema.settings).where(eq(schema.settings.key, 'update_center_k3s_config_v1')).get();
expect(savedRow?.value).toContain('metapi-deploy-helper.ai.svc.cluster.local');
const statusResponse = await app.inject({
method: 'GET',
url: '/api/update-center/status',
});
expect(statusResponse.statusCode).toBe(200);
expect(statusResponse.json()).toMatchObject({
currentVersion: '1.2.3',
config: {
enabled: true,
namespace: 'ai',
releaseName: 'metapi',
defaultDeploySource: 'github-release',
},
githubRelease: {
normalizedVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: '1.3.1',
},
helper: {
ok: true,
healthy: true,
releaseName: 'metapi',
},
});
});
it('returns partial status when a single version source lookup fails', async () => {
fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
fetchLatestDockerHubTagMock.mockResolvedValue({
source: 'docker-hub-tag',
rawVersion: '1.3.1',
normalizedVersion: '1.3.1',
url: null,
});
getUpdateCenterHelperStatusMock.mockResolvedValue({
ok: true,
releaseName: 'metapi',
namespace: 'ai',
revision: '12',
imageRepository: '1467078763/metapi',
imageTag: '1.2.3',
healthy: true,
});
await saveValidConfig();
const statusResponse = await app.inject({
method: 'GET',
url: '/api/update-center/status',
});
expect(statusResponse.statusCode).toBe(200);
expect(statusResponse.json()).toMatchObject({
githubRelease: null,
dockerHubTag: {
normalizedVersion: '1.3.1',
},
helper: {
ok: true,
healthy: true,
},
});
});
it('dedupes deploy requests while a task is already running', async () => {
await saveValidConfig();
let releaseDeploy: (() => void) | null = null;
const deployGate = new Promise<void>((resolve) => {
releaseDeploy = resolve;
});
streamUpdateCenterDeployMock.mockImplementation(async (_input: unknown, onLog?: (message: string) => void) => {
onLog?.('Running helm upgrade');
await deployGate;
onLog?.('Deployment complete');
return {
success: true,
targetSource: 'github-release',
targetTag: '1.3.0',
previousRevision: '12',
finalRevision: '13',
rolledBack: false,
logLines: ['Running helm upgrade', 'Deployment complete'],
};
});
const firstResponse = await app.inject({
method: 'POST',
url: '/api/update-center/deploy',
payload: {
source: 'github-release',
targetVersion: '1.3.0',
},
});
const secondResponse = await app.inject({
method: 'POST',
url: '/api/update-center/deploy',
payload: {
source: 'github-release',
targetVersion: '1.3.0',
},
});
expect(firstResponse.statusCode).toBe(202);
expect(secondResponse.statusCode).toBe(202);
const firstBody = firstResponse.json() as { task?: { id: string }; reused?: boolean };
const secondBody = secondResponse.json() as { task?: { id: string }; reused?: boolean };
expect(firstBody.task?.id).toBeTruthy();
expect(secondBody.task?.id).toBe(firstBody.task?.id);
expect(secondBody.reused).toBe(true);
releaseDeploy?.();
});
it('rejects deploy requests when the update center is disabled', async () => {
const saveResponse = await app.inject({
method: 'PUT',
url: '/api/update-center/config',
payload: {
enabled: false,
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',
},
});
expect(saveResponse.statusCode).toBe(200);
const deployResponse = await app.inject({
method: 'POST',
url: '/api/update-center/deploy',
payload: {
source: 'github-release',
targetVersion: '1.3.0',
},
});
expect(deployResponse.statusCode).toBe(400);
expect(deployResponse.json()).toMatchObject({
success: false,
message: 'update center is disabled',
});
});
it('streams deployment logs for known tasks and rejects unknown task ids', async () => {
await saveValidConfig();
const missingResponse = await app.inject({
method: 'GET',
url: '/api/update-center/tasks/missing-task/stream',
});
expect(missingResponse.statusCode).toBe(404);
streamUpdateCenterDeployMock.mockImplementation(async (_input: unknown, onLog?: (message: string) => void) => {
onLog?.('Resolving target version');
onLog?.('Waiting for rollout');
return {
success: true,
targetSource: 'docker-hub-tag',
targetTag: '1.3.1',
previousRevision: '13',
finalRevision: '14',
rolledBack: false,
logLines: ['Resolving target version', 'Waiting for rollout'],
};
});
const deployResponse = await app.inject({
method: 'POST',
url: '/api/update-center/deploy',
payload: {
source: 'docker-hub-tag',
targetVersion: '1.3.1',
},
});
const deployBody = deployResponse.json() as { task: { id: string } };
for (let attempt = 0; attempt < 20; attempt += 1) {
const task = getBackgroundTask?.(deployBody.task.id);
if (task?.status === 'succeeded') break;
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(getBackgroundTask?.(deployBody.task.id)?.logs).toEqual(expect.arrayContaining([
expect.objectContaining({ message: 'Resolving target version' }),
expect.objectContaining({ message: 'Waiting for rollout' }),
]));
const streamResponse = await app.inject({
method: 'GET',
url: `/api/update-center/tasks/${deployBody.task.id}/stream`,
});
expect(streamResponse.statusCode).toBe(200);
expect(streamResponse.headers['content-type']).toContain('text/event-stream');
expect(streamResponse.body).toContain('event: log');
expect(streamResponse.body).toContain('Resolving target version');
expect(streamResponse.body).toContain('Waiting for rollout');
expect(streamResponse.body).toContain('event: done');
});
});
+267
View File
@@ -0,0 +1,267 @@
import { FastifyInstance } from 'fastify';
import {
appendBackgroundTaskLog,
getBackgroundTask,
listBackgroundTasks,
startBackgroundTask,
subscribeToBackgroundTaskLogs,
} from '../../services/backgroundTaskService.js';
import {
fetchLatestDockerHubTag,
fetchLatestStableGitHubRelease,
getCurrentRuntimeVersion,
type UpdateCenterVersionCandidate,
type UpdateCenterVersionSource,
} from '../../services/updateCenterVersionService.js';
import {
getDefaultUpdateCenterConfig,
loadUpdateCenterConfig,
normalizeUpdateCenterConfig,
saveUpdateCenterConfig,
type UpdateCenterConfig,
} from '../../services/updateCenterConfigService.js';
import {
getUpdateCenterHelperStatus,
streamUpdateCenterDeploy,
} from '../../services/updateCenterHelperClient.js';
type DeployBody = {
source?: UpdateCenterVersionSource;
targetVersion?: string;
};
const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = 'update-center.deploy';
function getUpdateCenterHelperToken(): string {
return String(process.env.DEPLOY_HELPER_TOKEN || process.env.UPDATE_CENTER_HELPER_TOKEN || '').trim();
}
function assertDeployableConfig(config: UpdateCenterConfig) {
if (!config.enabled) throw new Error('update center is disabled');
if (!config.helperBaseUrl) throw new Error('helperBaseUrl is required');
if (!config.namespace) throw new Error('namespace is required');
if (!config.releaseName) throw new Error('releaseName is required');
if (!config.chartRef) throw new Error('chartRef is required');
if (!config.imageRepository) throw new Error('imageRepository is required');
if (!getUpdateCenterHelperToken()) throw new Error('DEPLOY_HELPER_TOKEN is required');
}
function getDeployTasks() {
return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE);
}
function summarizeHelperError(error: unknown) {
if (error instanceof Error && error.message) return error.message;
return String(error || 'unknown helper error');
}
async function settleOptional<T>(enabled: boolean, loader: () => Promise<T>): Promise<{
value: T | null;
error: string | null;
}> {
if (!enabled) {
return {
value: null,
error: null,
};
}
try {
return {
value: await loader(),
error: null,
};
} catch (error) {
return {
value: null,
error: summarizeHelperError(error),
};
}
}
async function buildUpdateCenterStatus() {
const config = await loadUpdateCenterConfig();
const helperToken = getUpdateCenterHelperToken();
const [githubLookup, dockerLookup, helperLookup] = await Promise.all([
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()),
settleOptional(!!config.helperBaseUrl, async () => {
if (!helperToken) {
throw new Error('DEPLOY_HELPER_TOKEN is required');
}
return await getUpdateCenterHelperStatus(config, helperToken);
}),
]);
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null;
const helper: Record<string, unknown> = helperLookup.value || {
ok: false,
healthy: false,
error: helperLookup.error,
};
const tasks = getDeployTasks();
const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null;
const lastFinishedTask = tasks.find((task) => task.status === 'succeeded' || task.status === 'failed') || null;
return {
currentVersion: getCurrentRuntimeVersion(),
config,
githubRelease,
dockerHubTag,
helper,
runningTask,
lastFinishedTask,
};
}
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`);
}
export async function updateCenterRoutes(app: FastifyInstance) {
app.get('/api/update-center/status', async () => {
return await buildUpdateCenterStatus();
});
app.post('/api/update-center/check', async () => {
return await buildUpdateCenterStatus();
});
app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => {
const next = normalizeUpdateCenterConfig(request.body || getDefaultUpdateCenterConfig());
const saved = await saveUpdateCenterConfig(next);
return {
success: true,
config: saved,
};
});
app.post<{ Body: DeployBody }>('/api/update-center/deploy', async (request, reply) => {
const config = await loadUpdateCenterConfig();
try {
assertDeployableConfig(config);
} catch (error) {
return reply.code(400).send({
success: false,
message: summarizeHelperError(error),
});
}
const source = request.body?.source === 'docker-hub-tag'
? 'docker-hub-tag'
: request.body?.source === 'github-release'
? 'github-release'
: config.defaultDeploySource;
const targetVersion = String(request.body?.targetVersion || '').trim();
if (!targetVersion) {
return reply.code(400).send({
success: false,
message: 'targetVersion is required',
});
}
let taskId = '';
const { task, reused } = startBackgroundTask(
{
type: UPDATE_CENTER_DEPLOY_TASK_TYPE,
title: '更新中心部署',
dedupeKey: UPDATE_CENTER_DEPLOY_DEDUPE_KEY,
successTitle: '更新中心部署已完成',
failureTitle: '更新中心部署失败',
},
async () => {
await Promise.resolve();
appendBackgroundTaskLog(taskId, `Resolving target version: ${targetVersion}`);
appendBackgroundTaskLog(taskId, `Contacting deploy helper: ${config.helperBaseUrl}`);
const result = await streamUpdateCenterDeploy(
{
config,
helperToken: getUpdateCenterHelperToken(),
source,
targetVersion,
},
(message) => {
appendBackgroundTaskLog(taskId, message);
},
);
if (!result.success) {
throw new Error('deploy helper reported a failed deployment');
}
appendBackgroundTaskLog(taskId, result.rolledBack ? 'Deployment rolled back' : 'Deployment finished successfully');
return result;
},
);
taskId = task.id;
return reply.code(202).send({
success: true,
reused,
task,
});
});
app.get<{ Params: { id: string } }>('/api/update-center/tasks/:id/stream', async (request, reply) => {
const taskId = String(request.params.id || '').trim();
const task = getBackgroundTask(taskId);
if (!task) {
return reply.code(404).send({
success: false,
message: 'task not found',
});
}
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 writeDone = () => {
const latest = getBackgroundTask(taskId);
writeSseEvent(reply, 'done', {
status: latest?.status || 'unknown',
});
reply.raw.end();
};
for (const entry of task.logs) {
writeSseEvent(reply, 'log', entry);
}
if (task.status !== 'pending' && task.status !== 'running') {
writeDone();
return;
}
const unsubscribe = subscribeToBackgroundTaskLogs(taskId, (entry) => {
writeSseEvent(reply, 'log', entry);
});
const interval = setInterval(() => {
const latest = getBackgroundTask(taskId);
if (!latest) {
clearInterval(interval);
unsubscribe();
writeDone();
return;
}
if (latest.status !== 'pending' && latest.status !== 'running') {
clearInterval(interval);
unsubscribe();
writeDone();
}
}, 25);
interval.unref?.();
request.raw.on('close', () => {
clearInterval(interval);
unsubscribe();
});
});
}