759 lines
25 KiB
TypeScript
759 lines
25 KiB
TypeScript
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,
|
|
streamUpdateCenterRollbackMock,
|
|
} = vi.hoisted(() => ({
|
|
fetchLatestStableGitHubReleaseMock: vi.fn(),
|
|
fetchLatestDockerHubTagMock: vi.fn(),
|
|
getUpdateCenterHelperStatusMock: vi.fn(),
|
|
streamUpdateCenterDeployMock: vi.fn(),
|
|
streamUpdateCenterRollbackMock: 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),
|
|
streamUpdateCenterRollback: (...args: unknown[]) => streamUpdateCenterRollbackMock(...args),
|
|
}));
|
|
|
|
type DbModule = typeof import('../../db/index.js');
|
|
type ConfigModule = typeof import('../../config.js');
|
|
type RuntimeStateModule = typeof import('../../services/updateCenterRuntimeStateService.js');
|
|
|
|
describe('update center routes', () => {
|
|
let app: FastifyInstance;
|
|
let db: DbModule['db'];
|
|
let schema: DbModule['schema'];
|
|
let appConfig: ConfigModule['config'];
|
|
let saveUpdateCenterRuntimeState: RuntimeStateModule['saveUpdateCenterRuntimeState'];
|
|
let loadUpdateCenterRuntimeState: RuntimeStateModule['loadUpdateCenterRuntimeState'];
|
|
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 configModule = await import('../../config.js');
|
|
const dbModule = await import('../../db/index.js');
|
|
const routesModule = await import('./updateCenter.js');
|
|
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
|
|
const runtimeStateModule = await import('../../services/updateCenterRuntimeStateService.js');
|
|
|
|
appConfig = configModule.config;
|
|
db = dbModule.db;
|
|
schema = dbModule.schema;
|
|
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
|
|
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
|
|
saveUpdateCenterRuntimeState = runtimeStateModule.saveUpdateCenterRuntimeState;
|
|
loadUpdateCenterRuntimeState = runtimeStateModule.loadUpdateCenterRuntimeState;
|
|
|
|
app = Fastify();
|
|
await app.register(routesModule.updateCenterRoutes);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
fetchLatestStableGitHubReleaseMock.mockReset();
|
|
fetchLatestDockerHubTagMock.mockReset();
|
|
getUpdateCenterHelperStatusMock.mockReset();
|
|
streamUpdateCenterDeployMock.mockReset();
|
|
streamUpdateCenterRollbackMock.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: 'latest',
|
|
normalizedVersion: 'latest',
|
|
tagName: 'latest',
|
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
publishedAt: '2026-03-29T11:54:35.591877Z',
|
|
url: null,
|
|
});
|
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
|
ok: true,
|
|
releaseName: 'metapi',
|
|
namespace: 'ai',
|
|
revision: '12',
|
|
imageRepository: '1467078763/metapi',
|
|
imageTag: 'latest',
|
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
|
healthy: true,
|
|
history: [
|
|
{
|
|
revision: '11',
|
|
updatedAt: '2026-03-28T12:00:00Z',
|
|
status: 'superseded',
|
|
description: 'Rollback to stable digest',
|
|
imageRepository: '1467078763/metapi',
|
|
imageTag: 'main',
|
|
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
},
|
|
],
|
|
});
|
|
|
|
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: 'latest',
|
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
},
|
|
helper: {
|
|
ok: true,
|
|
healthy: true,
|
|
releaseName: 'metapi',
|
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
|
history: [
|
|
{
|
|
revision: '11',
|
|
imageTag: 'main',
|
|
},
|
|
],
|
|
},
|
|
runtime: {
|
|
lastCheckedAt: expect.any(String),
|
|
lastCheckError: null,
|
|
lastResolvedSource: 'github-release',
|
|
lastResolvedDisplayVersion: '1.3.0',
|
|
lastResolvedCandidateKey: 'github-release:1.3.0',
|
|
lastNotifiedCandidateKey: null,
|
|
lastNotifiedAt: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
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('uses the shared config helper token when request-time env lookup is unavailable', 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',
|
|
});
|
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
|
ok: true,
|
|
releaseName: 'metapi',
|
|
namespace: 'ai',
|
|
revision: '12',
|
|
imageRepository: '1467078763/metapi',
|
|
imageTag: '1.2.3',
|
|
healthy: true,
|
|
});
|
|
|
|
await saveValidConfig();
|
|
|
|
const originalEnvToken = process.env.DEPLOY_HELPER_TOKEN;
|
|
delete process.env.DEPLOY_HELPER_TOKEN;
|
|
(appConfig as typeof appConfig & { deployHelperToken?: string }).deployHelperToken = 'helper-token';
|
|
|
|
try {
|
|
const statusResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/update-center/status',
|
|
});
|
|
|
|
expect(statusResponse.statusCode).toBe(200);
|
|
expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
|
}),
|
|
'helper-token',
|
|
);
|
|
expect(statusResponse.json()).toMatchObject({
|
|
helper: {
|
|
ok: true,
|
|
healthy: true,
|
|
},
|
|
});
|
|
} finally {
|
|
process.env.DEPLOY_HELPER_TOKEN = originalEnvToken;
|
|
delete (appConfig as typeof appConfig & { deployHelperToken?: string }).deployHelperToken;
|
|
}
|
|
});
|
|
|
|
it('reuses the persisted snapshot for status requests instead of re-querying external sources', async () => {
|
|
await saveValidConfig();
|
|
await saveUpdateCenterRuntimeState({
|
|
lastCheckedAt: '2026-03-31 09:00:00',
|
|
lastCheckError: null,
|
|
lastResolvedSource: 'docker-hub-tag',
|
|
lastResolvedDisplayVersion: 'latest @ sha256:efb2ee655386',
|
|
lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
lastNotifiedCandidateKey: null,
|
|
lastNotifiedAt: null,
|
|
statusSnapshot: {
|
|
githubRelease: {
|
|
source: 'github-release',
|
|
rawVersion: 'v1.3.0',
|
|
normalizedVersion: '1.3.0',
|
|
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
|
tagName: 'v1.3.0',
|
|
digest: null,
|
|
displayVersion: '1.3.0',
|
|
publishedAt: '2026-03-31T09:00:00Z',
|
|
},
|
|
dockerHubTag: {
|
|
source: 'docker-hub-tag',
|
|
rawVersion: 'latest',
|
|
normalizedVersion: 'latest',
|
|
tagName: 'latest',
|
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
publishedAt: '2026-03-31T09:00:00Z',
|
|
url: null,
|
|
},
|
|
helper: {
|
|
ok: true,
|
|
releaseName: 'metapi',
|
|
namespace: 'ai',
|
|
revision: '12',
|
|
imageRepository: '1467078763/metapi',
|
|
imageTag: 'latest',
|
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
|
healthy: true,
|
|
error: null,
|
|
history: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const statusResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/update-center/status',
|
|
});
|
|
|
|
expect(statusResponse.statusCode).toBe(200);
|
|
expect(statusResponse.json()).toMatchObject({
|
|
githubRelease: {
|
|
normalizedVersion: '1.3.0',
|
|
},
|
|
dockerHubTag: {
|
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
|
},
|
|
helper: {
|
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
|
},
|
|
runtime: {
|
|
lastCheckedAt: '2026-03-31 09:00:00',
|
|
},
|
|
});
|
|
expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled();
|
|
expect(fetchLatestDockerHubTagMock).not.toHaveBeenCalled();
|
|
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forces a live refresh on manual check and persists the refreshed snapshot', async () => {
|
|
await saveValidConfig();
|
|
fetchLatestStableGitHubReleaseMock.mockResolvedValue({
|
|
source: 'github-release',
|
|
rawVersion: 'v1.3.1',
|
|
normalizedVersion: '1.3.1',
|
|
tagName: 'v1.3.1',
|
|
displayVersion: '1.3.1',
|
|
publishedAt: '2026-03-31T10:00:00Z',
|
|
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1',
|
|
});
|
|
fetchLatestDockerHubTagMock.mockResolvedValue({
|
|
source: 'docker-hub-tag',
|
|
rawVersion: 'latest',
|
|
normalizedVersion: 'latest',
|
|
tagName: 'latest',
|
|
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
|
displayVersion: 'latest @ sha256:dddddddddddd',
|
|
publishedAt: '2026-03-31T10:00:00Z',
|
|
url: null,
|
|
});
|
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
|
ok: true,
|
|
releaseName: 'metapi',
|
|
namespace: 'ai',
|
|
revision: '13',
|
|
imageRepository: '1467078763/metapi',
|
|
imageTag: 'latest',
|
|
imageDigest: 'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
|
healthy: true,
|
|
history: [],
|
|
});
|
|
|
|
const checkResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/update-center/check',
|
|
});
|
|
|
|
expect(checkResponse.statusCode).toBe(200);
|
|
expect(checkResponse.json()).toMatchObject({
|
|
githubRelease: {
|
|
normalizedVersion: '1.3.1',
|
|
},
|
|
dockerHubTag: {
|
|
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
|
},
|
|
helper: {
|
|
revision: '13',
|
|
},
|
|
runtime: {
|
|
lastCheckedAt: expect.any(String),
|
|
},
|
|
});
|
|
expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchLatestDockerHubTagMock).toHaveBeenCalledTimes(1);
|
|
expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1);
|
|
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
|
|
lastResolvedSource: 'github-release',
|
|
lastResolvedDisplayVersion: '1.3.1',
|
|
statusSnapshot: {
|
|
githubRelease: expect.objectContaining({
|
|
normalizedVersion: '1.3.1',
|
|
}),
|
|
dockerHubTag: expect.objectContaining({
|
|
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
|
}),
|
|
helper: expect.objectContaining({
|
|
revision: '13',
|
|
}),
|
|
},
|
|
}));
|
|
});
|
|
|
|
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',
|
|
targetDigest: null,
|
|
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('forwards digest-aware deploy requests to the helper client', async () => {
|
|
await saveValidConfig();
|
|
|
|
streamUpdateCenterDeployMock.mockResolvedValue({
|
|
success: true,
|
|
targetSource: 'docker-hub-tag',
|
|
targetTag: 'latest',
|
|
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
previousRevision: '13',
|
|
finalRevision: '14',
|
|
rolledBack: false,
|
|
logLines: ['Running helm upgrade'],
|
|
});
|
|
|
|
const deployResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/update-center/deploy',
|
|
payload: {
|
|
source: 'docker-hub-tag',
|
|
targetTag: 'latest',
|
|
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
},
|
|
});
|
|
|
|
expect(deployResponse.statusCode).toBe(202);
|
|
expect(streamUpdateCenterDeployMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
source: 'docker-hub-tag',
|
|
targetTag: 'latest',
|
|
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
}), expect.any(Function));
|
|
});
|
|
|
|
it('rejects deploy requests when the target image is already running', async () => {
|
|
await saveValidConfig();
|
|
|
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
|
ok: true,
|
|
releaseName: 'metapi',
|
|
namespace: 'ai',
|
|
revision: '17',
|
|
imageRepository: '1467078763/metapi',
|
|
imageTag: 'latest',
|
|
imageDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
healthy: true,
|
|
});
|
|
|
|
const deployResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/update-center/deploy',
|
|
payload: {
|
|
source: 'docker-hub-tag',
|
|
targetTag: 'latest',
|
|
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
|
},
|
|
});
|
|
|
|
expect(deployResponse.statusCode).toBe(409);
|
|
expect(deployResponse.json()).toMatchObject({
|
|
success: false,
|
|
message: 'target image is already running',
|
|
});
|
|
expect(streamUpdateCenterDeployMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reject a same-version deploy when the requested digest differs from the running image', async () => {
|
|
await saveValidConfig();
|
|
|
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
|
ok: true,
|
|
releaseName: 'metapi',
|
|
namespace: 'ai',
|
|
revision: '17',
|
|
imageRepository: '1467078763/metapi',
|
|
imageTag: '1.2.3',
|
|
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
healthy: true,
|
|
});
|
|
streamUpdateCenterDeployMock.mockResolvedValue({
|
|
success: true,
|
|
targetSource: 'docker-hub-tag',
|
|
targetTag: '1.2.3',
|
|
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
previousRevision: '17',
|
|
finalRevision: '18',
|
|
rolledBack: false,
|
|
logLines: ['Running helm upgrade'],
|
|
});
|
|
|
|
const deployResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/update-center/deploy',
|
|
payload: {
|
|
source: 'docker-hub-tag',
|
|
targetTag: '1.2.3',
|
|
targetDigest: 'sha256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
|
},
|
|
});
|
|
|
|
expect(deployResponse.statusCode).toBe(202);
|
|
expect(streamUpdateCenterDeployMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
targetTag: '1.2.3',
|
|
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
}), expect.any(Function));
|
|
});
|
|
|
|
it('normalizes invalid target digests to null before forwarding the deploy request', async () => {
|
|
await saveValidConfig();
|
|
|
|
streamUpdateCenterDeployMock.mockResolvedValue({
|
|
success: true,
|
|
targetSource: 'docker-hub-tag',
|
|
targetTag: 'latest',
|
|
targetDigest: null,
|
|
previousRevision: '13',
|
|
finalRevision: '14',
|
|
rolledBack: false,
|
|
logLines: ['Running helm upgrade'],
|
|
});
|
|
|
|
const deployResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/update-center/deploy',
|
|
payload: {
|
|
source: 'docker-hub-tag',
|
|
targetTag: 'latest',
|
|
targetDigest: 'not-a-real-digest',
|
|
},
|
|
});
|
|
|
|
expect(deployResponse.statusCode).toBe(202);
|
|
expect(streamUpdateCenterDeployMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
targetTag: 'latest',
|
|
targetDigest: null,
|
|
}), expect.any(Function));
|
|
});
|
|
|
|
it('starts rollback tasks for explicit revision requests', async () => {
|
|
await saveValidConfig();
|
|
|
|
streamUpdateCenterRollbackMock.mockResolvedValue({
|
|
success: true,
|
|
targetRevision: '11',
|
|
finalRevision: '15',
|
|
logLines: ['Running helm rollback'],
|
|
});
|
|
|
|
const rollbackResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/update-center/rollback',
|
|
payload: {
|
|
targetRevision: '11',
|
|
},
|
|
});
|
|
|
|
expect(rollbackResponse.statusCode).toBe(202);
|
|
expect(streamUpdateCenterRollbackMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
targetRevision: '11',
|
|
}), expect.any(Function));
|
|
});
|
|
|
|
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',
|
|
targetDigest: null,
|
|
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');
|
|
});
|
|
});
|