mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): add bounded legacy panel cron adapter
This commit is contained in:
@@ -37,6 +37,7 @@ import type {
|
||||
LocalApiSecretListRoute,
|
||||
LocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export type LocalApiAdmissionOperation =
|
||||
@@ -125,6 +126,13 @@ export type LocalApiAdmissionOperation =
|
||||
| Readonly<{
|
||||
operationId: 'secret.put';
|
||||
projectId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'panel.cron.list';
|
||||
projectId: string;
|
||||
page: number;
|
||||
size: number;
|
||||
maximumRows: number;
|
||||
}>;
|
||||
|
||||
export interface LocalApiAdmissionRequest {
|
||||
@@ -168,6 +176,7 @@ export interface LocalApiAdmissionOptions {
|
||||
readonly triggerPutRoute: LocalApiTriggerPutRoute;
|
||||
readonly secretListRoute: LocalApiSecretListRoute;
|
||||
readonly secretPutRoute: LocalApiSecretPutRoute;
|
||||
readonly panelCronListRoute: PanelCronListRoute;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
@@ -253,6 +262,7 @@ export function createLocalApiAdmission(
|
||||
typeof options.triggerPutRoute?.handle !== 'function' ||
|
||||
typeof options.secretListRoute?.handle !== 'function' ||
|
||||
typeof options.secretPutRoute?.handle !== 'function' ||
|
||||
typeof options.panelCronListRoute?.handle !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
@@ -393,7 +403,8 @@ export function createLocalApiAdmission(
|
||||
: request.operation.operationId === 'task.list' ||
|
||||
request.operation.operationId === 'task.get' ||
|
||||
request.operation.operationId === 'trigger.list' ||
|
||||
request.operation.operationId === 'trigger.get'
|
||||
request.operation.operationId === 'trigger.get' ||
|
||||
request.operation.operationId === 'panel.cron.list'
|
||||
? 'task.read'
|
||||
: request.operation.operationId === 'secret.list'
|
||||
? 'secret.manage'
|
||||
@@ -553,6 +564,14 @@ export function createLocalApiAdmission(
|
||||
? { after: request.operation.after }
|
||||
: {}),
|
||||
});
|
||||
case 'panel.cron.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.panelCronListRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
page: request.operation.page,
|
||||
size: request.operation.size,
|
||||
maximumRows: request.operation.maximumRows,
|
||||
});
|
||||
case 'task.put':
|
||||
case 'task.authoring':
|
||||
case 'trigger.put':
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
createLocalApiSecretListRoute,
|
||||
createLocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||
|
||||
export interface LocalApiProductSurfaceEvent {
|
||||
@@ -221,6 +222,10 @@ export function createLocalApiProductSurface(
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
const panelCronListRoute = createPanelCronListRoute({
|
||||
tasks: authority.taskDefinitions,
|
||||
triggers: authority.triggers,
|
||||
});
|
||||
const admission = createLocalApiAdmission({
|
||||
authenticator,
|
||||
policy,
|
||||
@@ -241,6 +246,7 @@ export function createLocalApiProductSurface(
|
||||
triggerPutRoute,
|
||||
secretListRoute,
|
||||
secretPutRoute,
|
||||
panelCronListRoute,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
|
||||
createBuiltInTriggerSpecSemanticRegistry,
|
||||
normalizeTriggerRecord,
|
||||
type TriggerSource,
|
||||
} from '@qinglong/runtime-core/trigger';
|
||||
import {
|
||||
normalizeTaskDefinitionRecord,
|
||||
type TaskDefinitionSource,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
const MAX_PANEL_PAGE_SIZE = 64;
|
||||
|
||||
export interface PanelCronListRequest {
|
||||
readonly projectId: string;
|
||||
readonly page: number;
|
||||
readonly size: number;
|
||||
readonly maximumRows: number;
|
||||
}
|
||||
|
||||
export interface PanelCronListRoute {
|
||||
handle(request: Readonly<PanelCronListRequest>): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export interface PanelCronListSources {
|
||||
readonly tasks: Pick<TaskDefinitionSource, 'findTaskDefinitionRevision'>;
|
||||
readonly triggers: Pick<TriggerSource, 'listTriggers'>;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): LocalApiResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function validRequest(request: Readonly<PanelCronListRequest>): boolean {
|
||||
return (
|
||||
typeof request.projectId === 'string' &&
|
||||
request.projectId.length > 0 &&
|
||||
Buffer.byteLength(request.projectId, 'utf8') <= 128 &&
|
||||
Number.isSafeInteger(request.page) &&
|
||||
request.page >= 1 &&
|
||||
Number.isSafeInteger(request.size) &&
|
||||
request.size >= 1 &&
|
||||
request.size <= MAX_PANEL_PAGE_SIZE &&
|
||||
Number.isSafeInteger(request.maximumRows) &&
|
||||
request.maximumRows >= 1 &&
|
||||
request.maximumRows <= 256 &&
|
||||
request.page * request.size <= request.maximumRows
|
||||
);
|
||||
}
|
||||
|
||||
export function createPanelCronListRoute(
|
||||
sources: Readonly<PanelCronListSources>,
|
||||
): Readonly<PanelCronListRoute> {
|
||||
if (
|
||||
!sources ||
|
||||
typeof sources !== 'object' ||
|
||||
Array.isArray(sources) ||
|
||||
typeof sources.tasks?.findTaskDefinitionRevision !== 'function' ||
|
||||
typeof sources.triggers?.listTriggers !== 'function'
|
||||
) {
|
||||
throw new TypeError('Panel Cron list sources are invalid');
|
||||
}
|
||||
const semantics = createBuiltInTriggerSpecSemanticRegistry();
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<PanelCronListRequest>) {
|
||||
if (!validRequest(request)) {
|
||||
return response(400, { code: 400, message: '参数错误' });
|
||||
}
|
||||
try {
|
||||
const scanLimit = request.page * request.size;
|
||||
const page = await sources.triggers.listTriggers({
|
||||
projectId: request.projectId,
|
||||
limit: scanLimit,
|
||||
});
|
||||
if (
|
||||
!page ||
|
||||
!Array.isArray(page.triggers) ||
|
||||
page.triggers.length > scanLimit ||
|
||||
typeof page.truncated !== 'boolean' ||
|
||||
page.truncated !== Boolean(page.next)
|
||||
) {
|
||||
throw new TypeError('Trigger page is unavailable');
|
||||
}
|
||||
const start = (request.page - 1) * request.size;
|
||||
const selected = page.triggers.slice(start, scanLimit);
|
||||
const data: Record<string, unknown>[] = [];
|
||||
for (const rawTrigger of selected) {
|
||||
const trigger = normalizeTriggerRecord(rawTrigger);
|
||||
if (
|
||||
trigger.projectId !== request.projectId ||
|
||||
trigger.spec.schema !== BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA
|
||||
) {
|
||||
throw new TypeError('Trigger cannot be projected as a Cron');
|
||||
}
|
||||
const spec = semantics.normalize({
|
||||
projectId: trigger.projectId,
|
||||
triggerId: trigger.triggerId,
|
||||
taskId: trigger.taskId,
|
||||
taskRevision: trigger.taskRevision,
|
||||
spec: trigger.spec,
|
||||
});
|
||||
const rawTask = await sources.tasks.findTaskDefinitionRevision(
|
||||
request.projectId,
|
||||
trigger.taskId,
|
||||
trigger.taskRevision,
|
||||
);
|
||||
if (!rawTask) throw new TypeError('Pinned Task is unavailable');
|
||||
const task = normalizeTaskDefinitionRecord(rawTask);
|
||||
if (
|
||||
task.projectId !== request.projectId ||
|
||||
task.taskId !== trigger.taskId ||
|
||||
task.revision !== trigger.taskRevision ||
|
||||
task.contentDigest !== trigger.taskContentDigest
|
||||
) {
|
||||
throw new TypeError('Pinned Task identity is detached');
|
||||
}
|
||||
const disabled = !task.enabled || !trigger.enabled;
|
||||
data.push(
|
||||
Object.freeze({
|
||||
id: trigger.triggerId,
|
||||
name: task.name,
|
||||
command: `ql3:${task.kind}:${task.taskId}@${task.revision}`,
|
||||
schedule: spec.config.expression,
|
||||
extra_schedules: Object.freeze([]),
|
||||
status: disabled ? 2 : 1,
|
||||
isDisabled: disabled ? 1 : 0,
|
||||
isPinned: 0,
|
||||
createdAt: new Date(trigger.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(trigger.updatedAtMs).toISOString(),
|
||||
ql3: Object.freeze({
|
||||
projectId: request.projectId,
|
||||
taskId: task.taskId,
|
||||
taskRevision: task.revision,
|
||||
triggerId: trigger.triggerId,
|
||||
triggerRevision: trigger.revision,
|
||||
timezone: spec.config.timezone,
|
||||
misfirePolicy: spec.config.misfirePolicy,
|
||||
readOnly: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return response(200, {
|
||||
code: 200,
|
||||
data: Object.freeze({
|
||||
data: Object.freeze(data),
|
||||
total: page.triggers.length + (page.truncated ? 1 : 0),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
return response(503, {
|
||||
code: 503,
|
||||
message: 'QL3 面板兼容视图暂不可用',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -64,7 +64,8 @@ type LocalApiRouteResolution =
|
||||
| 'invalid_run_log_read_query'
|
||||
| 'invalid_task_list_query'
|
||||
| 'invalid_trigger_list_query'
|
||||
| 'invalid_secret_list_query';
|
||||
| 'invalid_secret_list_query'
|
||||
| 'invalid_panel_cron_list_query';
|
||||
}>;
|
||||
|
||||
export interface LocalApiHttpSurfaceOptions {
|
||||
@@ -549,6 +550,62 @@ function parseRunAttemptLogReadQuery(
|
||||
return Object.freeze({ offset, length });
|
||||
}
|
||||
|
||||
function parsePanelCronListRoute(
|
||||
rawUrl: string,
|
||||
profile: LocalApplicationProfile,
|
||||
): LocalApiRouteResolution | null {
|
||||
const separator = rawUrl.indexOf('?');
|
||||
if (
|
||||
separator !== rawUrl.lastIndexOf('?') ||
|
||||
(separator < 0 ? rawUrl : rawUrl.slice(0, separator)) !== '/api/crons'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const query = new URLSearchParams(
|
||||
separator < 0 ? '' : rawUrl.slice(separator + 1),
|
||||
);
|
||||
const allowed = new Set(['filters', 'page', 'searchValue', 'size', 't']);
|
||||
for (const key of query.keys()) {
|
||||
if (!allowed.has(key) || query.getAll(key).length !== 1) {
|
||||
throw new TypeError();
|
||||
}
|
||||
}
|
||||
const searchValue = query.get('searchValue') ?? '';
|
||||
const filters = query.get('filters') ?? '{}';
|
||||
const rawPage = query.get('page') ?? '1';
|
||||
const rawSize = query.get('size') ?? '20';
|
||||
const timestamp = query.get('t');
|
||||
const page = Number(rawPage);
|
||||
const size = Number(rawSize);
|
||||
const maximumRows = profile === 'edge' ? 64 : 256;
|
||||
if (
|
||||
searchValue !== '' ||
|
||||
filters !== '{}' ||
|
||||
!Number.isSafeInteger(page) ||
|
||||
page < 1 ||
|
||||
String(page) !== rawPage ||
|
||||
!Number.isSafeInteger(size) ||
|
||||
size < 1 ||
|
||||
size > 64 ||
|
||||
String(size) !== rawSize ||
|
||||
page * size > maximumRows ||
|
||||
(timestamp !== null && !/^\d{1,20}$/u.test(timestamp))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
operationId: 'panel.cron.list',
|
||||
projectId: 'default',
|
||||
page,
|
||||
size,
|
||||
maximumRows,
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_panel_cron_list_query' });
|
||||
}
|
||||
}
|
||||
|
||||
function route(
|
||||
request: IncomingMessage,
|
||||
profile: LocalApplicationProfile,
|
||||
@@ -558,11 +615,15 @@ function route(
|
||||
typeof rawUrl !== 'string' ||
|
||||
rawUrl.length < 1 ||
|
||||
Buffer.byteLength(rawUrl, 'utf8') > MAX_URL_BYTES ||
|
||||
rawUrl.includes('%') ||
|
||||
rawUrl.includes('#')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
const panelCronList = parsePanelCronListRoute(rawUrl, profile);
|
||||
if (panelCronList) return panelCronList;
|
||||
}
|
||||
if (rawUrl.includes('%')) return null;
|
||||
const separator = rawUrl.indexOf('?');
|
||||
if (separator !== rawUrl.lastIndexOf('?')) return null;
|
||||
const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator);
|
||||
|
||||
@@ -303,6 +303,17 @@ function fixture(overrides = {}) {
|
||||
return { statusCode: 201, body: { status: 'inserted' } };
|
||||
},
|
||||
},
|
||||
panelCronListRoute: {
|
||||
async handle(value) {
|
||||
events.push(
|
||||
`panel-crons:${value.projectId}:${value.page}:${value.size}:${value.maximumRows}`,
|
||||
);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { code: 200, data: { data: [], total: 0 } },
|
||||
};
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||
...overrides,
|
||||
@@ -508,6 +519,35 @@ test('uses task.read with a route-owned task.get audit identity', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses task.read and the exact panel Cron audit identity for the compatibility projection', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: Object.freeze({
|
||||
operationId: 'panel.cron.list',
|
||||
projectId: 'default',
|
||||
page: 1,
|
||||
size: 20,
|
||||
maximumRows: 64,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{
|
||||
statusCode: 200,
|
||||
body: { code: 200, data: { data: [], total: 0 } },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:task.read:default',
|
||||
'audit:allowed:panel.cron.list',
|
||||
'confirm',
|
||||
'panel-crons:default:1:20:64',
|
||||
]);
|
||||
});
|
||||
|
||||
test('authorizes and audits run.stop before exposing the cancellation body handler', async () => {
|
||||
const { admission, events } = fixture();
|
||||
const prepared = await admission.prepare(
|
||||
|
||||
@@ -152,6 +152,16 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
body: { secrets: [], truncated: false },
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'panel.cron.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
data: { data: [], total: 0 },
|
||||
input: value.operation,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
||||
@@ -249,6 +259,34 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
taskId: 'task_1',
|
||||
});
|
||||
|
||||
const panelCrons = await request(
|
||||
port,
|
||||
'/api/crons?searchValue=&page=1&size=20&filters=%7B%7D&t=100',
|
||||
);
|
||||
assert.equal(panelCrons.statusCode, 200);
|
||||
assert.deepEqual(panelCrons.body, {
|
||||
code: 200,
|
||||
data: { data: [], total: 0 },
|
||||
input: {
|
||||
operationId: 'panel.cron.list',
|
||||
projectId: 'default',
|
||||
page: 1,
|
||||
size: 20,
|
||||
maximumRows: 64,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(observed[6].operation, panelCrons.body.input);
|
||||
|
||||
const unsupportedPanelQuery = await request(
|
||||
port,
|
||||
'/api/crons?searchValue=private&page=1&size=20&filters=%7B%7D',
|
||||
);
|
||||
assert.deepEqual(unsupportedPanelQuery.body, {
|
||||
code: 'invalid_panel_cron_list_query',
|
||||
});
|
||||
assert.equal(unsupportedPanelQuery.statusCode, 400);
|
||||
assert.equal(observed.length, 7);
|
||||
|
||||
const cancellationBody = JSON.stringify({
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId: 'mutation-1',
|
||||
@@ -268,7 +306,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(cancellation.statusCode, 202);
|
||||
assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
|
||||
assert.deepEqual(observed[6].operation, {
|
||||
assert.deepEqual(observed[7].operation, {
|
||||
operationId: 'run.cancel',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
@@ -295,7 +333,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(taskStart.statusCode, 202);
|
||||
assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
|
||||
assert.deepEqual(observed[7].operation, {
|
||||
assert.deepEqual(observed[8].operation, {
|
||||
operationId: 'task.start',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
@@ -319,20 +357,20 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(taskPut.statusCode, 202);
|
||||
assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
|
||||
assert.deepEqual(observed[8].operation, {
|
||||
assert.deepEqual(observed[9].operation, {
|
||||
operationId: 'task.put',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
assert.equal(observed[8].localPresence, 'ql3p_request_bound_proof');
|
||||
assert.equal(observed[8].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||
assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof');
|
||||
assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||
|
||||
const log = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32',
|
||||
);
|
||||
assert.deepEqual(log.body, { range: { offset: 4, length: 32 } });
|
||||
assert.deepEqual(observed[9].operation, {
|
||||
assert.deepEqual(observed[10].operation, {
|
||||
operationId: 'run.log.read',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
@@ -361,19 +399,19 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
},
|
||||
);
|
||||
assert.equal(authoring.statusCode, 200);
|
||||
assert.deepEqual(observed[11].operation, {
|
||||
assert.deepEqual(observed[12].operation, {
|
||||
operationId: 'task.authoring',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
assert.equal(observed[11].localPresence, 'ql3p_authoring_read_proof');
|
||||
assert.equal(observed[12].localPresence, 'ql3p_authoring_read_proof');
|
||||
|
||||
const secrets = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
|
||||
);
|
||||
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
|
||||
assert.deepEqual(observed[12].operation, {
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
operationId: 'secret.list',
|
||||
projectId: 'prj_default',
|
||||
limit: 8,
|
||||
@@ -397,11 +435,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(secretPut.statusCode, 202);
|
||||
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
assert.deepEqual(observed[14].operation, {
|
||||
operationId: 'secret.put',
|
||||
projectId: 'prj_default',
|
||||
});
|
||||
assert.equal(observed[13].localPresence, 'ql3p_secret_bound_proof');
|
||||
assert.equal(observed[14].localPresence, 'ql3p_secret_bound_proof');
|
||||
|
||||
for (const invalidPath of [
|
||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||
@@ -483,7 +521,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
assert.equal(observed.length, 14);
|
||||
assert.equal(observed.length, 15);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['stopped', 'stopped'],
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPanelCronListRoute,
|
||||
} = require('../dist/panel-compatibility/panelCronListRoute.js');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
createTriggerRecord,
|
||||
createBuiltInTriggerSpecSemanticRegistry,
|
||||
} = require('@qinglong/runtime-core/trigger');
|
||||
|
||||
function task(taskId, revision = 2, enabled = true) {
|
||||
return createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'default',
|
||||
taskId,
|
||||
expectedRevision: revision - 1,
|
||||
mutationId: `019f7300-0000-4000-8000-${String(revision).padStart(
|
||||
12,
|
||||
'0',
|
||||
)}`,
|
||||
name: `Task ${taskId}`,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: { command: ['/bin/private'] },
|
||||
},
|
||||
labels: { private: 'redacted' },
|
||||
enabled,
|
||||
occurredAtMs: 200,
|
||||
},
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
function trigger(taskRecord, triggerId, enabled = true) {
|
||||
const semantics = createBuiltInTriggerSpecSemanticRegistry();
|
||||
const spec = semantics.normalize({
|
||||
projectId: 'default',
|
||||
triggerId,
|
||||
taskId: taskRecord.taskId,
|
||||
taskRevision: taskRecord.revision,
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '0 * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
});
|
||||
return createTriggerRecord(
|
||||
{
|
||||
projectId: 'default',
|
||||
triggerId,
|
||||
expectedRevision: null,
|
||||
mutationId: `019f7300-0000-4000-8001-${
|
||||
triggerId.endsWith('b') ? '000000000002' : '000000000001'
|
||||
}`,
|
||||
taskId: taskRecord.taskId,
|
||||
taskRevision: taskRecord.revision,
|
||||
taskContentDigest: taskRecord.contentDigest,
|
||||
spec,
|
||||
enabled,
|
||||
occurredAtMs: 400,
|
||||
},
|
||||
300,
|
||||
);
|
||||
}
|
||||
|
||||
test('projects one bounded page of pinned QL3 cron triggers into the legacy panel envelope', async () => {
|
||||
const taskA = task('task-a');
|
||||
const taskB = task('task-b', 3, false);
|
||||
const triggers = [
|
||||
trigger(taskA, 'cron:task-a'),
|
||||
trigger(taskB, 'cron:task-b'),
|
||||
];
|
||||
const calls = [];
|
||||
const route = createPanelCronListRoute({
|
||||
tasks: {
|
||||
async findTaskDefinitionRevision(projectId, taskId, revision) {
|
||||
calls.push(['task', projectId, taskId, revision]);
|
||||
return [taskA, taskB].find(
|
||||
(entry) => entry.taskId === taskId && entry.revision === revision,
|
||||
);
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
async listTriggers(input) {
|
||||
calls.push(['trigger', input]);
|
||||
return {
|
||||
triggers: triggers.slice(0, input.limit),
|
||||
truncated: triggers.length > input.limit,
|
||||
...(triggers.length > input.limit
|
||||
? { next: { triggerId: triggers[input.limit - 1].triggerId } }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = await route.handle({
|
||||
projectId: 'default',
|
||||
page: 1,
|
||||
size: 1,
|
||||
maximumRows: 64,
|
||||
});
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(first.body.code, 200);
|
||||
assert.equal(first.body.data.total, 2);
|
||||
assert.deepEqual(first.body.data.data, [
|
||||
{
|
||||
id: 'cron:task-a',
|
||||
name: 'Task task-a',
|
||||
command: 'ql3:command:task-a@2',
|
||||
schedule: '0 * * * *',
|
||||
extra_schedules: [],
|
||||
status: 1,
|
||||
isDisabled: 0,
|
||||
isPinned: 0,
|
||||
createdAt: new Date(300).toISOString(),
|
||||
updatedAt: new Date(400).toISOString(),
|
||||
ql3: {
|
||||
projectId: 'default',
|
||||
taskId: 'task-a',
|
||||
taskRevision: 2,
|
||||
triggerId: 'cron:task-a',
|
||||
triggerRevision: 1,
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const second = await route.handle({
|
||||
projectId: 'default',
|
||||
page: 2,
|
||||
size: 1,
|
||||
maximumRows: 64,
|
||||
});
|
||||
assert.equal(second.body.data.total, 2);
|
||||
assert.equal(second.body.data.data[0].id, 'cron:task-b');
|
||||
assert.equal(second.body.data.data[0].status, 2);
|
||||
assert.equal(second.body.data.data[0].isDisabled, 1);
|
||||
assert.deepEqual(calls[0], ['trigger', { projectId: 'default', limit: 1 }]);
|
||||
assert.deepEqual(calls[2], ['trigger', { projectId: 'default', limit: 2 }]);
|
||||
|
||||
const afterEnd = await route.handle({
|
||||
projectId: 'default',
|
||||
page: 3,
|
||||
size: 1,
|
||||
maximumRows: 64,
|
||||
});
|
||||
assert.deepEqual(afterEnd.body.data, { data: [], total: 2 });
|
||||
});
|
||||
|
||||
test('fails closed for detached pins, unsupported triggers, invalid budgets and unavailable storage', async () => {
|
||||
const pinned = task('task-a');
|
||||
const cron = trigger(pinned, 'cron:task-a');
|
||||
for (const sources of [
|
||||
{
|
||||
tasks: {
|
||||
async findTaskDefinitionRevision() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
async listTriggers() {
|
||||
return { triggers: [cron], truncated: false };
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tasks: {
|
||||
async findTaskDefinitionRevision() {
|
||||
return pinned;
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
async listTriggers() {
|
||||
return {
|
||||
triggers: [
|
||||
{ ...cron, spec: { schema: 'vendor/event@v1', config: {} } },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tasks: {
|
||||
async findTaskDefinitionRevision() {
|
||||
return pinned;
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
async listTriggers() {
|
||||
throw new Error('offline');
|
||||
},
|
||||
},
|
||||
},
|
||||
]) {
|
||||
const route = createPanelCronListRoute(sources);
|
||||
assert.deepEqual(
|
||||
await route.handle({
|
||||
projectId: 'default',
|
||||
page: 1,
|
||||
size: 1,
|
||||
maximumRows: 64,
|
||||
}),
|
||||
{
|
||||
statusCode: 503,
|
||||
body: { code: 503, message: 'QL3 面板兼容视图暂不可用' },
|
||||
},
|
||||
);
|
||||
}
|
||||
const route = createPanelCronListRoute({
|
||||
tasks: {
|
||||
async findTaskDefinitionRevision() {
|
||||
return pinned;
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
async listTriggers() {
|
||||
return { triggers: [], truncated: false };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await route.handle({
|
||||
projectId: 'default',
|
||||
page: 5,
|
||||
size: 20,
|
||||
maximumRows: 64,
|
||||
})
|
||||
).statusCode,
|
||||
400,
|
||||
);
|
||||
assert.throws(() => createPanelCronListRoute({}), TypeError);
|
||||
});
|
||||
@@ -836,6 +836,46 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
assert.equal(triggerList.body.triggers[0].triggerId, 'cron:task-1');
|
||||
assert.equal(triggerList.body.triggers[0].spec, undefined);
|
||||
|
||||
const panelCrons = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/crons?searchValue=&page=1&size=20&filters=%7B%7D',
|
||||
);
|
||||
assert.equal(panelCrons.statusCode, 200, JSON.stringify(panelCrons));
|
||||
assert.equal(panelCrons.body.code, 200);
|
||||
assert.equal(panelCrons.body.data.total, 1);
|
||||
assert.equal(panelCrons.body.data.data.length, 1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
id: panelCrons.body.data.data[0].id,
|
||||
name: panelCrons.body.data.data[0].name,
|
||||
command: panelCrons.body.data.data[0].command,
|
||||
schedule: panelCrons.body.data.data[0].schedule,
|
||||
status: panelCrons.body.data.data[0].status,
|
||||
isDisabled: panelCrons.body.data.data[0].isDisabled,
|
||||
ql3: panelCrons.body.data.data[0].ql3,
|
||||
},
|
||||
{
|
||||
id: 'cron:task-1',
|
||||
name: 'Local API Task updated',
|
||||
command: 'ql3:command:task-1@2',
|
||||
schedule: '0 * * * *',
|
||||
status: 1,
|
||||
isDisabled: 0,
|
||||
ql3: {
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 2,
|
||||
triggerId: 'cron:task-1',
|
||||
triggerRevision: 1,
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(JSON.stringify(panelCrons).includes('/bin/echo'), false);
|
||||
|
||||
const triggerRead = await request(port, `Bearer ${TOKEN}`, triggerPath);
|
||||
assert.equal(triggerRead.statusCode, 200);
|
||||
assert.deepEqual(triggerRead.body.trigger.spec, {
|
||||
|
||||
Reference in New Issue
Block a user