forked from boos/BoosAPI
[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,113 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetBackgroundTasksForTests,
|
||||
appendBackgroundTaskLog,
|
||||
getBackgroundTask,
|
||||
startBackgroundTask,
|
||||
subscribeToBackgroundTaskLogs,
|
||||
} from './backgroundTaskService.js';
|
||||
|
||||
describe('background task log streaming', () => {
|
||||
afterEach(() => {
|
||||
__resetBackgroundTasksForTests();
|
||||
});
|
||||
|
||||
it('appends log entries in order and exposes them through task lookups', async () => {
|
||||
let releaseRunner: (() => void) | null = null;
|
||||
const runnerGate = new Promise<void>((resolve) => {
|
||||
releaseRunner = resolve;
|
||||
});
|
||||
|
||||
const { task } = startBackgroundTask(
|
||||
{
|
||||
type: 'update-center.deploy',
|
||||
title: '更新部署',
|
||||
},
|
||||
async () => {
|
||||
await runnerGate;
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
appendBackgroundTaskLog(task.id, 'Resolving target version');
|
||||
appendBackgroundTaskLog(task.id, 'Running helm upgrade');
|
||||
|
||||
const currentTask = getBackgroundTask(task.id);
|
||||
expect(currentTask?.logs).toEqual([
|
||||
expect.objectContaining({ seq: 1, message: 'Resolving target version' }),
|
||||
expect.objectContaining({ seq: 2, message: 'Running helm upgrade' }),
|
||||
]);
|
||||
|
||||
releaseRunner?.();
|
||||
});
|
||||
|
||||
it('notifies subscribers when new log entries arrive', async () => {
|
||||
let releaseRunner: (() => void) | null = null;
|
||||
const runnerGate = new Promise<void>((resolve) => {
|
||||
releaseRunner = resolve;
|
||||
});
|
||||
|
||||
const { task } = startBackgroundTask(
|
||||
{
|
||||
type: 'update-center.deploy',
|
||||
title: '更新部署',
|
||||
},
|
||||
async () => {
|
||||
await runnerGate;
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
const received: string[] = [];
|
||||
const unsubscribe = subscribeToBackgroundTaskLogs(task.id, (entry) => {
|
||||
received.push(entry.message);
|
||||
});
|
||||
|
||||
appendBackgroundTaskLog(task.id, 'Waiting for rollout');
|
||||
appendBackgroundTaskLog(task.id, 'Deployment complete');
|
||||
|
||||
expect(received).toEqual([
|
||||
'Waiting for rollout',
|
||||
'Deployment complete',
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
releaseRunner?.();
|
||||
});
|
||||
|
||||
it('trims old log entries to a bounded buffer', async () => {
|
||||
let releaseRunner: (() => void) | null = null;
|
||||
const runnerGate = new Promise<void>((resolve) => {
|
||||
releaseRunner = resolve;
|
||||
});
|
||||
|
||||
const { task } = startBackgroundTask(
|
||||
{
|
||||
type: 'update-center.deploy',
|
||||
title: '更新部署',
|
||||
},
|
||||
async () => {
|
||||
await runnerGate;
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
|
||||
for (let index = 1; index <= 250; index += 1) {
|
||||
appendBackgroundTaskLog(task.id, `line-${index}`);
|
||||
}
|
||||
|
||||
const currentTask = getBackgroundTask(task.id);
|
||||
expect(currentTask?.logs).toHaveLength(200);
|
||||
expect(currentTask?.logs[0]).toMatchObject({
|
||||
seq: 51,
|
||||
message: 'line-51',
|
||||
});
|
||||
expect(currentTask?.logs.at(-1)).toMatchObject({
|
||||
seq: 250,
|
||||
message: 'line-250',
|
||||
});
|
||||
|
||||
releaseRunner?.();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,12 @@ import { sendNotification } from './notifyService.js';
|
||||
|
||||
export type BackgroundTaskStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
export type BackgroundTaskLogEntry = {
|
||||
seq: number;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type BackgroundTask = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -18,6 +24,7 @@ export type BackgroundTask = {
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
expiresAtMs: number;
|
||||
logs: BackgroundTaskLogEntry[];
|
||||
};
|
||||
|
||||
type TaskMessageTemplate = string | ((task: BackgroundTask) => string);
|
||||
@@ -37,9 +44,12 @@ type BackgroundTaskStartOptions = {
|
||||
|
||||
const TASK_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const TASK_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
const TASK_LOG_LIMIT = 200;
|
||||
|
||||
const tasks = new Map<string, BackgroundTask>();
|
||||
const dedupeTaskIds = new Map<string, string>();
|
||||
const taskLogSeq = new Map<string, number>();
|
||||
const taskLogSubscribers = new Map<string, Set<(entry: BackgroundTaskLogEntry) => void>>();
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function nowIso() {
|
||||
@@ -72,8 +82,9 @@ function resolveTaskMessage(template: TaskMessageTemplate | undefined, task: Bac
|
||||
}
|
||||
|
||||
function setTaskStatus(task: BackgroundTask, patch: Partial<BackgroundTask>) {
|
||||
const currentTask = tasks.get(task.id) || task;
|
||||
const next: BackgroundTask = {
|
||||
...task,
|
||||
...currentTask,
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
@@ -81,6 +92,67 @@ function setTaskStatus(task: BackgroundTask, patch: Partial<BackgroundTask>) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function cleanupTaskInternals(taskId: string) {
|
||||
taskLogSeq.delete(taskId);
|
||||
taskLogSubscribers.delete(taskId);
|
||||
}
|
||||
|
||||
export function appendBackgroundTaskLog(taskId: string, message: string): BackgroundTaskLogEntry | null {
|
||||
const task = tasks.get(taskId);
|
||||
const normalizedMessage = String(message || '').trim();
|
||||
if (!task || !normalizedMessage) return null;
|
||||
|
||||
const nextSeq = (taskLogSeq.get(taskId) || 0) + 1;
|
||||
taskLogSeq.set(taskId, nextSeq);
|
||||
|
||||
const entry: BackgroundTaskLogEntry = {
|
||||
seq: nextSeq,
|
||||
message: normalizedMessage,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
|
||||
const nextLogs = [...task.logs, entry];
|
||||
const trimmedLogs = nextLogs.length > TASK_LOG_LIMIT
|
||||
? nextLogs.slice(nextLogs.length - TASK_LOG_LIMIT)
|
||||
: nextLogs;
|
||||
|
||||
tasks.set(taskId, {
|
||||
...task,
|
||||
logs: trimmedLogs,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
|
||||
const subscribers = taskLogSubscribers.get(taskId);
|
||||
if (subscribers) {
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function subscribeToBackgroundTaskLogs(
|
||||
taskId: string,
|
||||
listener: (entry: BackgroundTaskLogEntry) => void,
|
||||
): () => void {
|
||||
let subscribers = taskLogSubscribers.get(taskId);
|
||||
if (!subscribers) {
|
||||
subscribers = new Set();
|
||||
taskLogSubscribers.set(taskId, subscribers);
|
||||
}
|
||||
subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
const current = taskLogSubscribers.get(taskId);
|
||||
if (!current) return;
|
||||
current.delete(listener);
|
||||
if (current.size <= 0) {
|
||||
taskLogSubscribers.delete(taskId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function appendTaskEvent(level: 'info' | 'warning' | 'error', title: string, message: string, taskId: string) {
|
||||
try {
|
||||
await db.insert(schema.events).values({
|
||||
@@ -154,6 +226,7 @@ function cleanupExpiredTasks() {
|
||||
if (task.dedupeKey && dedupeTaskIds.get(task.dedupeKey) === taskId) {
|
||||
dedupeTaskIds.delete(task.dedupeKey);
|
||||
}
|
||||
cleanupTaskInternals(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,9 +269,11 @@ export function startBackgroundTask(
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
expiresAtMs: Date.now() + Math.max(60_000, options.keepMs ?? TASK_TTL_MS),
|
||||
logs: [],
|
||||
};
|
||||
|
||||
tasks.set(task.id, task);
|
||||
taskLogSeq.set(task.id, 0);
|
||||
if (dedupeKey) dedupeTaskIds.set(dedupeKey, task.id);
|
||||
|
||||
appendTaskEvent('info', `${task.title}已开始`, `${task.title} 已开始执行`, task.id);
|
||||
@@ -246,6 +321,8 @@ export function summarizeCheckinResults(results: Array<{ result?: any }>): { tot
|
||||
export function __resetBackgroundTasksForTests() {
|
||||
tasks.clear();
|
||||
dedupeTaskIds.clear();
|
||||
taskLogSeq.clear();
|
||||
taskLogSubscribers.clear();
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
import type { UpdateCenterVersionSource } from './updateCenterVersionService.js';
|
||||
|
||||
export type UpdateCenterConfig = {
|
||||
enabled: boolean;
|
||||
helperBaseUrl: string;
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
chartRef: string;
|
||||
imageRepository: string;
|
||||
githubReleasesEnabled: boolean;
|
||||
dockerHubTagsEnabled: boolean;
|
||||
defaultDeploySource: UpdateCenterVersionSource;
|
||||
};
|
||||
|
||||
export const UPDATE_CENTER_CONFIG_SETTING_KEY = 'update_center_k3s_config_v1';
|
||||
|
||||
export function getDefaultUpdateCenterConfig(): UpdateCenterConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
helperBaseUrl: '',
|
||||
namespace: 'default',
|
||||
releaseName: '',
|
||||
chartRef: '',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown, fallback = ''): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizeUpdateCenterConfig(input: unknown): UpdateCenterConfig {
|
||||
const defaults = getDefaultUpdateCenterConfig();
|
||||
const record = input && typeof input === 'object' ? input as Record<string, unknown> : {};
|
||||
const defaultDeploySource = record.defaultDeploySource === 'docker-hub-tag'
|
||||
? 'docker-hub-tag'
|
||||
: 'github-release';
|
||||
|
||||
return {
|
||||
enabled: normalizeBoolean(record.enabled, defaults.enabled),
|
||||
helperBaseUrl: normalizeString(record.helperBaseUrl, defaults.helperBaseUrl),
|
||||
namespace: normalizeString(record.namespace, defaults.namespace) || defaults.namespace,
|
||||
releaseName: normalizeString(record.releaseName, defaults.releaseName),
|
||||
chartRef: normalizeString(record.chartRef, defaults.chartRef),
|
||||
imageRepository: normalizeString(record.imageRepository, defaults.imageRepository) || defaults.imageRepository,
|
||||
githubReleasesEnabled: normalizeBoolean(record.githubReleasesEnabled, defaults.githubReleasesEnabled),
|
||||
dockerHubTagsEnabled: normalizeBoolean(record.dockerHubTagsEnabled, defaults.dockerHubTagsEnabled),
|
||||
defaultDeploySource,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadUpdateCenterConfig(): Promise<UpdateCenterConfig> {
|
||||
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, UPDATE_CENTER_CONFIG_SETTING_KEY)).get();
|
||||
if (!row?.value) {
|
||||
return getDefaultUpdateCenterConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeUpdateCenterConfig(JSON.parse(row.value));
|
||||
} catch {
|
||||
return getDefaultUpdateCenterConfig();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUpdateCenterConfig(input: unknown): Promise<UpdateCenterConfig> {
|
||||
const next = normalizeUpdateCenterConfig(input);
|
||||
await upsertSetting(UPDATE_CENTER_CONFIG_SETTING_KEY, next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { getUpdateCenterHelperStatus } from './updateCenterHelperClient.js';
|
||||
|
||||
describe('update center helper client', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('maps helper status aborts to a timeout error', async () => {
|
||||
const abortError = new Error('aborted');
|
||||
(abortError as Error & { name: string }).name = 'AbortError';
|
||||
fetchMock.mockRejectedValue(abortError);
|
||||
|
||||
await expect(getUpdateCenterHelperStatus({
|
||||
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',
|
||||
}, 'helper-token')).rejects.toThrow('deploy helper status timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { fetch, type RequestInit as UndiciRequestInit } from 'undici';
|
||||
|
||||
import type { UpdateCenterConfig } from './updateCenterConfigService.js';
|
||||
import type { UpdateCenterVersionSource } from './updateCenterVersionService.js';
|
||||
|
||||
const UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type UpdateCenterHelperStatus = {
|
||||
ok: boolean;
|
||||
releaseName: string | null;
|
||||
namespace: string | null;
|
||||
revision: string | null;
|
||||
imageRepository: string | null;
|
||||
imageTag: string | null;
|
||||
healthy: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type UpdateCenterDeploySummary = {
|
||||
success: boolean;
|
||||
targetSource: UpdateCenterVersionSource;
|
||||
targetTag: string;
|
||||
previousRevision: string | null;
|
||||
finalRevision: string | null;
|
||||
rolledBack: boolean;
|
||||
logLines: string[];
|
||||
};
|
||||
|
||||
type HelperDeployLogEvent = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
async function fetchHelperJsonWithTimeout(url: string, init: UndiciRequestInit): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`helper status failed with HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`deploy helper status timeout (${Math.round(UPDATE_CENTER_HELPER_STATUS_TIMEOUT_MS / 1000)}s)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildHelperHeaders(token: string, accept = 'application/json') {
|
||||
return {
|
||||
authorization: `Bearer ${token}`,
|
||||
accept,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeSseEvent(event: string, data: unknown) {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
type SseChunkHandler = (event: string, data: unknown) => void;
|
||||
|
||||
function parseSseBuffer(buffer: string, onChunk: SseChunkHandler) {
|
||||
const events = buffer.split('\n\n');
|
||||
const remainder = events.pop() || '';
|
||||
|
||||
for (const block of events) {
|
||||
const lines = block.split('\n');
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice('event:'.length).trim() || 'message';
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice('data:'.length).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length <= 0) continue;
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
onChunk(eventName, JSON.parse(rawData));
|
||||
} catch {
|
||||
onChunk(eventName, rawData);
|
||||
}
|
||||
}
|
||||
|
||||
return remainder;
|
||||
}
|
||||
|
||||
export async function getUpdateCenterHelperStatus(
|
||||
config: UpdateCenterConfig,
|
||||
helperToken: string,
|
||||
): Promise<UpdateCenterHelperStatus> {
|
||||
const query = new URLSearchParams({
|
||||
namespace: config.namespace,
|
||||
releaseName: config.releaseName,
|
||||
});
|
||||
return await fetchHelperJsonWithTimeout(`${config.helperBaseUrl.replace(/\/$/, '')}/status?${query.toString()}`, {
|
||||
headers: buildHelperHeaders(helperToken),
|
||||
}) as UpdateCenterHelperStatus;
|
||||
}
|
||||
|
||||
export async function streamUpdateCenterDeploy(
|
||||
input: {
|
||||
config: UpdateCenterConfig;
|
||||
helperToken: string;
|
||||
source: UpdateCenterVersionSource;
|
||||
targetVersion: string;
|
||||
},
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<UpdateCenterDeploySummary> {
|
||||
const response = await fetch(`${input.config.helperBaseUrl.replace(/\/$/, '')}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...buildHelperHeaders(input.helperToken, 'text/event-stream'),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namespace: input.config.namespace,
|
||||
releaseName: input.config.releaseName,
|
||||
chartRef: input.config.chartRef,
|
||||
imageRepository: input.config.imageRepository,
|
||||
source: input.source,
|
||||
targetVersion: input.targetVersion,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`helper deploy failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('helper deploy response did not include a stream body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = response.body.getReader();
|
||||
let buffer = '';
|
||||
let finalResult: UpdateCenterDeploySummary | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = parseSseBuffer(buffer, (event, data) => {
|
||||
if (event === 'log' && data && typeof data === 'object') {
|
||||
const message = String((data as HelperDeployLogEvent).message || '').trim();
|
||||
if (message) onLog?.(message);
|
||||
return;
|
||||
}
|
||||
if (event === 'result') {
|
||||
finalResult = data as UpdateCenterDeploySummary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
parseSseBuffer(`${buffer}\n\n`, (event, data) => {
|
||||
if (event === 'log' && data && typeof data === 'object') {
|
||||
const message = String((data as HelperDeployLogEvent).message || '').trim();
|
||||
if (message) onLog?.(message);
|
||||
return;
|
||||
}
|
||||
if (event === 'result') {
|
||||
finalResult = data as UpdateCenterDeploySummary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error('helper deploy stream ended without a result event');
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
export const __updateCenterHelperClientTestUtils = {
|
||||
encodeSseEvent,
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { fetchMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
compareStableSemVer,
|
||||
fetchLatestDockerHubTag,
|
||||
fetchLatestStableGitHubRelease,
|
||||
parseStableSemVer,
|
||||
resolvePreferredDeploySource,
|
||||
selectLatestDockerHubTag,
|
||||
selectLatestStableGitHubRelease,
|
||||
} from './updateCenterVersionService.js';
|
||||
|
||||
describe('update center version service', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe('parseStableSemVer', () => {
|
||||
it('accepts stable semver strings with optional leading v', () => {
|
||||
expect(parseStableSemVer('1.2.3')).toMatchObject({
|
||||
raw: '1.2.3',
|
||||
normalized: '1.2.3',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
});
|
||||
expect(parseStableSemVer('v2.4.6')).toMatchObject({
|
||||
raw: 'v2.4.6',
|
||||
normalized: '2.4.6',
|
||||
major: 2,
|
||||
minor: 4,
|
||||
patch: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects prerelease and invalid versions', () => {
|
||||
expect(parseStableSemVer('v1.2.3-alpha.1')).toBeNull();
|
||||
expect(parseStableSemVer('latest')).toBeNull();
|
||||
expect(parseStableSemVer('2026-03-29')).toBeNull();
|
||||
expect(parseStableSemVer('1.2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareStableSemVer', () => {
|
||||
it('compares semver numerically instead of lexicographically', () => {
|
||||
const v120 = parseStableSemVer('1.2.0');
|
||||
const v1100 = parseStableSemVer('1.10.0');
|
||||
const v200 = parseStableSemVer('2.0.0');
|
||||
|
||||
expect(v120).not.toBeNull();
|
||||
expect(v1100).not.toBeNull();
|
||||
expect(v200).not.toBeNull();
|
||||
expect(compareStableSemVer(v120!, v1100!)).toBeLessThan(0);
|
||||
expect(compareStableSemVer(v200!, v1100!)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectLatestStableGitHubRelease', () => {
|
||||
it('ignores drafts and prereleases and returns the highest stable semver release', () => {
|
||||
const latest = selectLatestStableGitHubRelease([
|
||||
{
|
||||
tag_name: 'v1.2.3-alpha.1',
|
||||
html_url: 'https://example.com/pre',
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
published_at: '2026-03-29T00:00:00Z',
|
||||
name: 'v1.2.3-alpha.1',
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.9.9',
|
||||
html_url: 'https://example.com/older',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
published_at: '2026-03-28T00:00:00Z',
|
||||
name: 'v1.9.9',
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.10.0',
|
||||
html_url: 'https://example.com/latest',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
published_at: '2026-03-27T00:00:00Z',
|
||||
name: 'v1.10.0',
|
||||
},
|
||||
{
|
||||
tag_name: 'v9.9.9',
|
||||
html_url: 'https://example.com/draft',
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
published_at: '2026-03-30T00:00:00Z',
|
||||
name: 'v9.9.9',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(latest).toMatchObject({
|
||||
rawVersion: 'v1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
source: 'github-release',
|
||||
url: 'https://example.com/latest',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectLatestDockerHubTag', () => {
|
||||
it('ignores non-semver tags and prereleases', () => {
|
||||
const latest = selectLatestDockerHubTag([
|
||||
'latest',
|
||||
'main',
|
||||
'v1.2.3-beta.1',
|
||||
'v1.2.3',
|
||||
'1.10.0',
|
||||
'20260329',
|
||||
]);
|
||||
|
||||
expect(latest).toMatchObject({
|
||||
rawVersion: '1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
source: 'docker-hub-tag',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePreferredDeploySource', () => {
|
||||
it('prefers the configured default source when both channels have valid candidates', () => {
|
||||
const preferred = resolvePreferredDeploySource({
|
||||
defaultSource: 'github-release',
|
||||
githubRelease: {
|
||||
source: 'github-release',
|
||||
rawVersion: 'v1.10.0',
|
||||
normalizedVersion: '1.10.0',
|
||||
url: 'https://example.com/release',
|
||||
},
|
||||
dockerHubTag: {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: '1.10.1',
|
||||
normalizedVersion: '1.10.1',
|
||||
url: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(preferred?.source).toBe('github-release');
|
||||
expect(preferred?.normalizedVersion).toBe('1.10.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLatestStableGitHubRelease', () => {
|
||||
it('fetches releases and returns the latest stable semver record', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify([
|
||||
{
|
||||
tag_name: 'v1.2.3-beta.1',
|
||||
html_url: 'https://example.com/beta',
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.2.9',
|
||||
html_url: 'https://example.com/older',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
},
|
||||
{
|
||||
tag_name: 'v1.10.0',
|
||||
html_url: 'https://example.com/latest',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
},
|
||||
]), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const latest = await fetchLatestStableGitHubRelease();
|
||||
expect(latest).toMatchObject({
|
||||
source: 'github-release',
|
||||
normalizedVersion: '1.10.0',
|
||||
url: 'https://example.com/latest',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/repos/cita-777/metapi/releases');
|
||||
});
|
||||
|
||||
it('maps aborted GitHub lookups to a timeout error', async () => {
|
||||
const abortError = new Error('aborted');
|
||||
(abortError as Error & { name: string }).name = 'AbortError';
|
||||
fetchMock.mockRejectedValue(abortError);
|
||||
|
||||
await expect(fetchLatestStableGitHubRelease()).rejects.toThrow('GitHub releases lookup timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLatestDockerHubTag', () => {
|
||||
it('fetches tags and returns the latest semver tag only', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
results: [
|
||||
{ name: 'latest' },
|
||||
{ name: 'v1.2.3-rc.1' },
|
||||
{ name: 'v1.2.3' },
|
||||
{ name: '1.10.0' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const latest = await fetchLatestDockerHubTag();
|
||||
expect(latest).toMatchObject({
|
||||
source: 'docker-hub-tag',
|
||||
normalizedVersion: '1.10.0',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v2/repositories/1467078763/metapi/tags');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { fetch, type RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export type StableSemVer = {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
};
|
||||
|
||||
export type UpdateCenterVersionSource = 'github-release' | 'docker-hub-tag';
|
||||
|
||||
export type UpdateCenterVersionCandidate = {
|
||||
source: UpdateCenterVersionSource;
|
||||
rawVersion: string;
|
||||
normalizedVersion: string;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export type GitHubReleaseRecord = {
|
||||
tag_name?: string | null;
|
||||
html_url?: string | null;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
published_at?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100';
|
||||
const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||
|
||||
async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${timeoutLabel} failed with HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`${timeoutLabel} timeout (${Math.round(UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS / 1000)}s)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStableSemVer(input: string | null | undefined): StableSemVer | null {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return null;
|
||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
||||
if (!match) return null;
|
||||
const major = Number.parseInt(match[1], 10);
|
||||
const minor = Number.parseInt(match[2], 10);
|
||||
const patch = Number.parseInt(match[3], 10);
|
||||
if (![major, minor, patch].every(Number.isFinite)) return null;
|
||||
return {
|
||||
raw,
|
||||
normalized: `${major}.${minor}.${patch}`,
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareStableSemVer(a: StableSemVer, b: StableSemVer): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
export function selectLatestStableGitHubRelease(
|
||||
releases: GitHubReleaseRecord[],
|
||||
): UpdateCenterVersionCandidate | null {
|
||||
let selected: { semver: StableSemVer; release: GitHubReleaseRecord } | null = null;
|
||||
|
||||
for (const release of releases) {
|
||||
if (release?.draft || release?.prerelease) continue;
|
||||
const semver = parseStableSemVer(release?.tag_name);
|
||||
if (!semver) continue;
|
||||
if (!selected || compareStableSemVer(semver, selected.semver) > 0) {
|
||||
selected = { semver, release };
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
return {
|
||||
source: 'github-release',
|
||||
rawVersion: selected.release.tag_name || selected.semver.raw,
|
||||
normalizedVersion: selected.semver.normalized,
|
||||
url: selected.release.html_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectLatestDockerHubTag(tags: string[]): UpdateCenterVersionCandidate | null {
|
||||
let selected: StableSemVer | null = null;
|
||||
|
||||
for (const tag of tags) {
|
||||
const semver = parseStableSemVer(tag);
|
||||
if (!semver) continue;
|
||||
if (!selected || compareStableSemVer(semver, selected) > 0) {
|
||||
selected = semver;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
return {
|
||||
source: 'docker-hub-tag',
|
||||
rawVersion: selected.raw,
|
||||
normalizedVersion: selected.normalized,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePreferredDeploySource(input: {
|
||||
defaultSource: UpdateCenterVersionSource;
|
||||
githubRelease: UpdateCenterVersionCandidate | null;
|
||||
dockerHubTag: UpdateCenterVersionCandidate | null;
|
||||
}): UpdateCenterVersionCandidate | null {
|
||||
if (input.defaultSource === 'github-release') {
|
||||
return input.githubRelease || input.dockerHubTag;
|
||||
}
|
||||
return input.dockerHubTag || input.githubRelease;
|
||||
}
|
||||
|
||||
export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVersionCandidate | null> {
|
||||
const releases = await fetchJsonWithTimeout(GITHUB_RELEASES_URL, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
},
|
||||
}, 'GitHub releases lookup') as GitHubReleaseRecord[];
|
||||
return selectLatestStableGitHubRelease(Array.isArray(releases) ? releases : []);
|
||||
}
|
||||
|
||||
export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCandidate | null> {
|
||||
const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
},
|
||||
}, 'Docker Hub tag lookup') as { results?: Array<{ name?: string | null }> };
|
||||
const tags = Array.isArray(payload?.results)
|
||||
? payload.results.map((item) => String(item?.name || '')).filter(Boolean)
|
||||
: [];
|
||||
return selectLatestDockerHubTag(tags);
|
||||
}
|
||||
|
||||
export function getCurrentRuntimeVersion(): string {
|
||||
try {
|
||||
const packageJsonPath = resolve(process.cwd(), 'package.json');
|
||||
const payload = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string };
|
||||
const version = String(payload?.version || '').trim();
|
||||
return version || '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user