feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
use std::{collections::BTreeMap, str::FromStr};
use axum::http::{HeaderName, HeaderValue};
use chrono::Utc;
use chrono_tz::Tz;
use croner::Cron;
use sqlx::SqlitePool;
use url::Url;
use crate::{error::ApiResult, tasks::model::*};
use super::repository::{self, ListFilters};
const MAX_TASK_ID_LEN: usize = 64;
const MAX_TASK_NAME_LEN: usize = 120;
const MAX_TIMEOUT_SECS: u64 = 86_400;
const MAX_ARGS: usize = 256;
const MAX_ARG_LEN: usize = 2_048;
const MAX_ENV_ENTRIES: usize = 128;
const MAX_ENV_KEY_LEN: usize = 128;
const MAX_ENV_VALUE_LEN: usize = 8_192;
const MAX_HTTP_BODY_LEN: usize = 1_048_576;
const DEFAULT_PAGE_SIZE: u32 = 20;
const MAX_PAGE_SIZE: u32 = 100;
pub async fn create(pool: &SqlitePool, mut request: CreateTaskRequest) -> ApiResult<TaskResponse> {
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
validate_task_id(&request.id)?;
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::create(pool, &request).await
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<TaskResponse> {
repository::get(pool, id)
.await?
.ok_or_else(|| crate::error::ApiError::not_found(format!("任务 {id} 不存在")))
}
pub async fn update(
pool: &SqlitePool,
id: &str,
mut request: UpdateTaskRequest,
) -> ApiResult<TaskResponse> {
validate_task_id(id)?;
request.name = request.name.trim().to_owned();
request.cron = request.cron.trim().to_owned();
request.timezone = request.timezone.trim().to_owned();
validate_common(
&request.name,
&request.cron,
&request.timezone,
request.timeout_secs,
&request.retry_policy,
)?;
validate_action(&request.action)?;
repository::update(pool, id, &request).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
if repository::delete(pool, id).await? {
Ok(())
} else {
Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)))
}
}
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<TaskResponse> {
if !repository::set_enabled(pool, id, enabled).await? {
return Err(crate::error::ApiError::not_found(format!(
"任务 {id} 不存在"
)));
}
get(pool, id).await
}
pub async fn list(pool: &SqlitePool, query: ListTasksQuery) -> ApiResult<TaskListResponse> {
let page = query.page.unwrap_or(1).max(1);
let page_size = query
.page_size
.unwrap_or(DEFAULT_PAGE_SIZE)
.clamp(1, MAX_PAGE_SIZE);
let filters = ListFilters {
q: query
.q
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty()),
enabled: query.enabled,
action_type: query.action_type,
page,
page_size,
};
repository::list(pool, &filters).await
}
fn validate_task_id(id: &str) -> ApiResult<()> {
if id.is_empty() || id.len() > MAX_TASK_ID_LEN {
return Err(crate::error::ApiError::validation(
"id",
format!("任务标识长度必须在 1 到 {MAX_TASK_ID_LEN} 个字符之间"),
));
}
if !id
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
{
return Err(crate::error::ApiError::validation(
"id",
"任务标识只能包含 ASCII 字母、数字、下划线和连字符",
));
}
Ok(())
}
fn validate_common(
name: &str,
cron_expression: &str,
timezone: &str,
timeout_secs: u64,
retry_policy: &RetryPolicy,
) -> ApiResult<()> {
if name.is_empty() || name.chars().count() > MAX_TASK_NAME_LEN {
return Err(crate::error::ApiError::validation(
"name",
format!("任务名称长度必须在 1 到 {MAX_TASK_NAME_LEN} 个字符之间"),
));
}
if timeout_secs == 0 || timeout_secs > MAX_TIMEOUT_SECS {
return Err(crate::error::ApiError::validation(
"timeout_secs",
format!("超时时间必须在 1 到 {MAX_TIMEOUT_SECS} 秒之间"),
));
}
if retry_policy.max_attempts > 10 {
return Err(crate::error::ApiError::validation(
"retry_policy.max_attempts",
"最大重试次数不能超过 10",
));
}
if retry_policy.delay_secs > 3_600 {
return Err(crate::error::ApiError::validation(
"retry_policy.delay_secs",
"重试间隔不能超过 3600 秒",
));
}
Tz::from_str(timezone).map_err(|_| {
crate::error::ApiError::validation("timezone", "时区必须是有效的 IANA 时区名称")
})?;
let cron = Cron::from_str(cron_expression).map_err(|error| {
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
})?;
cron.find_next_occurrence(&Utc::now(), false)
.map_err(|error| {
crate::error::ApiError::validation(
"cron",
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
)
})?;
Ok(())
}
fn validate_action(action: &TaskAction) -> ApiResult<()> {
match action {
TaskAction::Script {
working_dir,
interpreter,
script,
args,
env,
} => {
validate_required("action.working_dir", working_dir)?;
validate_required("action.interpreter", interpreter)?;
validate_required("action.script", script)?;
validate_args(args)?;
validate_env(env)?;
}
TaskAction::Http {
url,
headers,
body,
expected_status,
..
} => {
let parsed_url = Url::parse(url).map_err(|error| {
crate::error::ApiError::validation("action.url", format!("URL 无效: {error}"))
})?;
if !matches!(parsed_url.scheme(), "http" | "https") || parsed_url.host_str().is_none() {
return Err(crate::error::ApiError::validation(
"action.url",
"URL 必须使用 http 或 https 协议并包含主机名",
));
}
validate_headers(headers)?;
if body
.as_ref()
.is_some_and(|value| value.len() > MAX_HTTP_BODY_LEN)
{
return Err(crate::error::ApiError::validation(
"action.body",
"HTTP Body 不能超过 1 MiB",
));
}
if expected_status
.iter()
.any(|status| !(100..=599).contains(status))
{
return Err(crate::error::ApiError::validation(
"action.expected_status",
"期望状态码必须在 100 到 599 之间",
));
}
}
TaskAction::Process {
executable,
working_dir,
args,
env,
} => {
validate_required("action.executable", executable)?;
if let Some(working_dir) = working_dir {
validate_required("action.working_dir", working_dir)?;
}
validate_args(args)?;
validate_env(env)?;
}
}
Ok(())
}
fn validate_required(field: &str, value: &str) -> ApiResult<()> {
if value.trim().is_empty() {
Err(crate::error::ApiError::validation(field, "该字段不能为空"))
} else {
Ok(())
}
}
fn validate_args(args: &[String]) -> ApiResult<()> {
if args.len() > MAX_ARGS {
return Err(crate::error::ApiError::validation(
"action.args",
format!("参数数量不能超过 {MAX_ARGS} 个"),
));
}
if args.iter().any(|argument| argument.len() > MAX_ARG_LEN) {
return Err(crate::error::ApiError::validation(
"action.args",
format!("单个参数不能超过 {MAX_ARG_LEN} 个字符"),
));
}
Ok(())
}
fn validate_env(env: &BTreeMap<String, String>) -> ApiResult<()> {
if env.len() > MAX_ENV_ENTRIES {
return Err(crate::error::ApiError::validation(
"action.env",
format!("环境变量数量不能超过 {MAX_ENV_ENTRIES} 个"),
));
}
for (key, value) in env {
if key.is_empty()
|| key.len() > MAX_ENV_KEY_LEN
|| key.contains(['=', '\0'])
|| value.len() > MAX_ENV_VALUE_LEN
|| value.contains('\0')
{
return Err(crate::error::ApiError::validation(
"action.env",
"环境变量名称或值无效",
));
}
}
Ok(())
}
fn validate_headers(headers: &BTreeMap<String, String>) -> ApiResult<()> {
for (name, value) in headers {
HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 名称无效")
})?;
HeaderValue::from_str(value).map_err(|_| {
crate::error::ApiError::validation("action.headers", "HTTP Header 值无效")
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use axum::response::IntoResponse;
use super::*;
fn script_action() -> TaskAction {
TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
interpreter: "python.exe".to_owned(),
script: "main.py".to_owned(),
args: vec!["--mode".to_owned(), "full".to_owned()],
env: BTreeMap::new(),
}
}
#[test]
fn accepts_valid_task_fields() {
validate_task_id("db_backup_daily").expect("task id should be valid");
validate_common(
"每日数据库备份",
"0 0 2 * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect("task fields should be valid");
validate_action(&script_action()).expect("script action should be valid");
}
#[test]
fn rejects_invalid_cron() {
let error = validate_common(
"Invalid cron",
"*/0 * * * * *",
"Asia/Shanghai",
300,
&RetryPolicy::default(),
)
.expect_err("invalid cron should fail");
assert_eq!(error.into_response().status(), 400);
}
#[test]
fn rejects_empty_script_path() {
let action = TaskAction::Script {
working_dir: "D:\\Projects\\sync".to_owned(),
interpreter: "python.exe".to_owned(),
script: " ".to_owned(),
args: Vec::new(),
env: BTreeMap::new(),
};
validate_action(&action).expect_err("empty script path should fail");
}
}