mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
fix: restore legacy cron schedules and shell diagnostics (#3078)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||||
import nodeSchedule from 'node-schedule';
|
import nodeSchedule from 'node-schedule';
|
||||||
|
import CronExpressionParser from 'cron-parser';
|
||||||
import { scheduleStacks } from './data';
|
import { scheduleStacks } from './data';
|
||||||
import { runCron } from '../shared/runCron';
|
import { runCron } from '../shared/runCron';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
@@ -17,12 +18,13 @@ const isValidCronField = (cron: string): boolean => {
|
|||||||
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
|
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
|
// 日期和星期字段的 ? 是合法通配符。解析完整表达式,避免将单独的
|
||||||
// cron-parser 接受但 node-schedule 拒绝,提前拦截
|
// ? 等无效规则放行后,在替换恢复快照时清除已有任务。
|
||||||
if (/\?/.test(cron)) {
|
try {
|
||||||
|
return CronExpressionParser.parse(cron).hasNext();
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCron = (
|
const addCron = (
|
||||||
@@ -38,7 +40,7 @@ const addCron = (
|
|||||||
if (!isValidCronField(schedule)) {
|
if (!isValidCronField(schedule)) {
|
||||||
validationErrors.push(
|
validationErrors.push(
|
||||||
tf(
|
tf(
|
||||||
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
'任务ID %s: 无效的 cron 表达式 "%s"',
|
||||||
String(id),
|
String(id),
|
||||||
schedule,
|
schedule,
|
||||||
),
|
),
|
||||||
@@ -50,7 +52,7 @@ const addCron = (
|
|||||||
if (!isValidCronField(x.schedule)) {
|
if (!isValidCronField(x.schedule)) {
|
||||||
validationErrors.push(
|
validationErrors.push(
|
||||||
tf(
|
tf(
|
||||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
|
||||||
String(id),
|
String(id),
|
||||||
x.schedule,
|
x.schedule,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1102,7 +1102,7 @@ export default class CronService {
|
|||||||
await cronClient.addCron(regularCrons, requireScheduler);
|
await cronClient.addCron(regularCrons, requireScheduler);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'[crontab] Failed to register cron job in scheduler:',
|
'[crontab] Failed to register cron job in scheduler: %s',
|
||||||
error?.message || error,
|
error?.message || error,
|
||||||
);
|
);
|
||||||
if (requireScheduler) throw error;
|
if (requireScheduler) throw error;
|
||||||
|
|||||||
+4
-4
@@ -142,10 +142,10 @@ const messages: Record<string, Record<string, string>> = {
|
|||||||
'Scheduler registration failed, task update rolled back',
|
'Scheduler registration failed, task update rolled back',
|
||||||
'调度器注册失败,任务启用已回滚':
|
'调度器注册失败,任务启用已回滚':
|
||||||
'Scheduler registration failed, task enable rolled back',
|
'Scheduler registration failed, task enable rolled back',
|
||||||
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
'任务ID %s: 无效的 cron 表达式 "%s"':
|
||||||
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
'Task ID %s: invalid cron expression "%s"',
|
||||||
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"':
|
||||||
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
'Task ID %s (extra_schedule): invalid cron expression "%s"',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ const validateSchedule = (value: string, helpers: any) => {
|
|||||||
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
|
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
|
||||||
return helpers.error('any.invalid');
|
return helpers.error('any.invalid');
|
||||||
}
|
}
|
||||||
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
|
|
||||||
if (/\?/.test(value)) {
|
|
||||||
return helpers.error('any.invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (CronExpressionParser.parse(value).hasNext()) {
|
if (CronExpressionParser.parse(value).hasNext()) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 青龙 2.22.0
|
# 青龙 2.22.0
|
||||||
|
|
||||||
|
## 2026-09-27 覆盖更新
|
||||||
|
|
||||||
|
- 修复历史任务中合法的 `?` 定时表达式被拒绝,导致启动恢复失败、面板健康检查返回 503 的问题(#3078)。主规则和附加规则均保留兼容性,无效规则仍在替换现有任务前拒绝。
|
||||||
|
- 修复 `ql check` 等 Shell 诊断输出以 `--` 开头时的 `printf: --: invalid option`,补全调度注册失败日志中的错误详情。
|
||||||
|
- 已使用真实调度任务验证恢复流程;本地回归 237 项,234 通过、3 项平台相关跳过,后端构建通过。
|
||||||
|
|
||||||
## 新增功能
|
## 新增功能
|
||||||
|
|
||||||
- 独立远程命令行包 `@whyour/qinglong-cli`:覆盖任务、订阅、环境变量、配置、脚本、日志、依赖、应用、系统等 OpenAPI,支持认证、JSON 输出和中英文帮助。
|
- 独立远程命令行包 `@whyour/qinglong-cli`:覆盖任务、订阅、环境变量、配置、脚本、日志、依赖、应用、系统等 OpenAPI,支持认证、JSON 输出和中英文帮助。
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ t() {
|
|||||||
fi
|
fi
|
||||||
[[ -z $msg ]] && msg="$key"
|
[[ -z $msg ]] && msg="$key"
|
||||||
# shellcheck disable=SC2059
|
# shellcheck disable=SC2059
|
||||||
printf "$msg\n" "$@"
|
printf -- "$msg\n" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
set_proxy() {
|
set_proxy() {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { format } = require('node:util');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||||
|
|
||||||
|
const legacySchedules = [
|
||||||
|
'* * * * *',
|
||||||
|
'*/5 * * * *',
|
||||||
|
'0 */10 * * * ?',
|
||||||
|
'0 */2 * * * ?',
|
||||||
|
'0 */5 * * * ?',
|
||||||
|
'0 0/30 * * * ?',
|
||||||
|
'0 0/5 * * * ?',
|
||||||
|
'0 0 12 ? * MON',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('API accepts legacy question-mark schedules but rejects malformed cron', () => {
|
||||||
|
const { scheduleSchema } = load('back/validation/schedule.ts', {
|
||||||
|
'../config': { logPath: '/ql/data/log' },
|
||||||
|
});
|
||||||
|
for (const schedule of [...legacySchedules, '@once', '@boot']) {
|
||||||
|
assert.equal(scheduleSchema.validate(schedule).error, undefined, schedule);
|
||||||
|
}
|
||||||
|
for (const schedule of ['?', '0 /5 * * * ?', '0 70 * * * ?', 'not a cron']) {
|
||||||
|
assert.ok(scheduleSchema.validate(schedule).error, schedule);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy main and extra schedules restore real jobs and healthy readiness', async (t) => {
|
||||||
|
const stacks = new Map();
|
||||||
|
t.after(() => {
|
||||||
|
for (const jobs of stacks.values()) for (const job of jobs) job.cancel();
|
||||||
|
});
|
||||||
|
const { addCron } = load('back/schedule/addCron.ts', {
|
||||||
|
'./data': { scheduleStacks: stacks },
|
||||||
|
'../shared/runCron': { runCron() {} },
|
||||||
|
'../loaders/logger': { info() {}, warn() {} },
|
||||||
|
'../shared/i18n': { tf: format },
|
||||||
|
});
|
||||||
|
const crons = legacySchedules.map((schedule, index) => ({
|
||||||
|
id: `legacy-${index}`, name: 'legacy', schedule, command: 'true',
|
||||||
|
extra_schedules: [{ schedule: '0 0/30 * * * ?' }],
|
||||||
|
}));
|
||||||
|
const register = (rows) => new Promise((resolve, reject) => {
|
||||||
|
addCron({ request: { replace: true, crons: rows } }, (error) =>
|
||||||
|
error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
|
const readiness = new SchedulerReadiness(async () => {}, 60000);
|
||||||
|
t.after(() => clearTimeout(readiness.retry));
|
||||||
|
readiness.configure(() => register(crons));
|
||||||
|
assert.equal(await readiness.recover(), true);
|
||||||
|
assert.equal(await readiness.check(), true);
|
||||||
|
assert.equal(stacks.size, crons.length);
|
||||||
|
for (const jobs of stacks.values()) {
|
||||||
|
assert.equal(jobs.length, 2);
|
||||||
|
assert.ok(jobs.every((job) => job.nextInvocation()));
|
||||||
|
}
|
||||||
|
const previousJobs = [...stacks.values()].flat();
|
||||||
|
for (const schedule of ['?', '0 /5 * * * ?', '0 70 * * * ?']) {
|
||||||
|
for (const invalid of [
|
||||||
|
{ ...crons[0], schedule },
|
||||||
|
{ ...crons[0], extra_schedules: [{ schedule }] },
|
||||||
|
]) {
|
||||||
|
await assert.rejects(register([invalid]), (error) => error.code === 3);
|
||||||
|
assert.deepEqual([...stacks.values()].flat(), previousJobs);
|
||||||
|
assert.ok(previousJobs.every((job) => job.nextInvocation()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
|
test('shell translations print leading dashes, placeholders and newlines', () => {
|
||||||
|
const result = spawnSync('bash', ['-c', String.raw`
|
||||||
|
source shell/share.sh
|
||||||
|
t '---> 服务诊断信息'
|
||||||
|
t '---> 最近的系统日志: %s' '/tmp/test.log'
|
||||||
|
t 'first\nsecond'
|
||||||
|
`], { encoding: 'utf8', env: { ...process.env, QL_DIR: process.cwd() } });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
assert.equal(result.stdout,
|
||||||
|
'---> 服务诊断信息\n---> 最近的系统日志: /tmp/test.log\nfirst\nsecond\n');
|
||||||
|
});
|
||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
version: 2.22.0
|
version: 2.22.0
|
||||||
changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md
|
changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.22.0.md
|
||||||
publishTime: 2026-09-26 0000
|
publishTime: 2026-09-27 1615
|
||||||
changeLog: |
|
changeLog: |
|
||||||
|
修复更新后含 ? 的历史定时规则导致面板 503,以及 ql check 的 printf 报错(#3078)
|
||||||
1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI
|
1. 新增独立远程 CLI,覆盖任务、订阅、环境变量、配置、脚本等 OpenAPI
|
||||||
2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口
|
2. 新增可选面板内部 TypeScript 工具,默认保留 Shell 入口
|
||||||
3. 仪表盘支持查看今日成功和失败任务明细
|
3. 仪表盘支持查看今日成功和失败任务明细
|
||||||
|
|||||||
Reference in New Issue
Block a user