feat: implement full-stack TaskPilot scheduler
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::{HeaderValue, Method, header},
|
||||
middleware,
|
||||
response::IntoResponse,
|
||||
routing::any,
|
||||
routing::get,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
|
||||
use crate::{
|
||||
cron::{self, CronPreviewRequest, CronPreviewResponse},
|
||||
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
|
||||
runs::{
|
||||
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
|
||||
routes::{
|
||||
__path_cancel_run, __path_download_run_logs, __path_execution_events, __path_get_run,
|
||||
__path_get_run_logs, __path_list_task_runs, __path_run_task,
|
||||
},
|
||||
},
|
||||
security,
|
||||
state::AppState,
|
||||
tasks::{
|
||||
model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, TaskAction,
|
||||
TaskActionType, TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
},
|
||||
routes::{
|
||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||
__path_get_task, __path_list_tasks, __path_update_task,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// Current service status.
|
||||
pub status: String,
|
||||
/// Stable service identifier.
|
||||
pub service: String,
|
||||
/// Backend package version.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SessionResponse {
|
||||
/// Random per-process token required for mutating browser requests.
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/health",
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "TaskPilot API is healthy", body = HealthResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn health() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_owned(),
|
||||
service: "task-pilot-backend".to_owned(),
|
||||
version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/session",
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "Local API session token", body = SessionResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn session(State(state): State<AppState>) -> Json<SessionResponse> {
|
||||
Json(SessionResponse {
|
||||
token: state.auth_token().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/cron/preview",
|
||||
tag = "system",
|
||||
request_body = CronPreviewRequest,
|
||||
responses(
|
||||
(status = 200, description = "Upcoming cron occurrences", body = CronPreviewResponse),
|
||||
(status = 400, description = "Invalid cron preview request", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn preview_cron(
|
||||
ApiJson(request): ApiJson<CronPreviewRequest>,
|
||||
) -> ApiResult<Json<CronPreviewResponse>> {
|
||||
if request.count == 0 || request.count > 10 {
|
||||
return Err(ApiError::validation("count", "预览次数必须在 1 到 10 之间"));
|
||||
}
|
||||
let preview = cron::preview_in_timezone(
|
||||
&request.expression,
|
||||
&request.timezone,
|
||||
chrono::Utc::now(),
|
||||
request.count,
|
||||
)
|
||||
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
|
||||
Ok(Json(preview))
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "TaskPilot API",
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
description = "Local task scheduling and process management API."
|
||||
),
|
||||
paths(
|
||||
health,
|
||||
session,
|
||||
preview_cron,
|
||||
list_tasks,
|
||||
create_task,
|
||||
get_task,
|
||||
update_task,
|
||||
delete_task,
|
||||
enable_task,
|
||||
disable_task,
|
||||
run_task,
|
||||
list_task_runs,
|
||||
get_run,
|
||||
get_run_logs,
|
||||
cancel_run,
|
||||
download_run_logs,
|
||||
execution_events
|
||||
),
|
||||
components(schemas(
|
||||
HealthResponse,
|
||||
SessionResponse,
|
||||
CronPreviewRequest,
|
||||
CronPreviewResponse,
|
||||
ErrorResponse,
|
||||
TaskActionType,
|
||||
HttpMethod,
|
||||
ConcurrencyPolicy,
|
||||
RetryPolicy,
|
||||
TaskAction,
|
||||
CreateTaskRequest,
|
||||
UpdateTaskRequest,
|
||||
TaskResponse,
|
||||
TaskListResponse,
|
||||
TaskRunResponse,
|
||||
TaskRunListResponse,
|
||||
LogChunkResponse
|
||||
))
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
pub fn openapi() -> utoipa::openapi::OpenApi {
|
||||
ApiDoc::openapi()
|
||||
}
|
||||
|
||||
async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
|
||||
Json(openapi())
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let allowed_origins = [
|
||||
HeaderValue::from_static("http://127.0.0.1:5173"),
|
||||
HeaderValue::from_static("http://localhost:5173"),
|
||||
];
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(allowed_origins)
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]);
|
||||
|
||||
let api = Router::new()
|
||||
.route("/api/v1/health", get(health))
|
||||
.route("/api/v1/session", get(session))
|
||||
.route("/api/v1/cron/preview", axum::routing::post(preview_cron))
|
||||
.route("/api/v1/openapi.json", get(openapi_json))
|
||||
.merge(crate::tasks::routes::router())
|
||||
.merge(crate::runs::routes::router())
|
||||
.route("/api/{*path}", any(api_not_found))
|
||||
.layer(cors)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
security::api_guard,
|
||||
));
|
||||
|
||||
api.with_state(state)
|
||||
}
|
||||
|
||||
async fn api_not_found() -> impl IntoResponse {
|
||||
ApiError::route_not_found("API 路由不存在")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::{env, error::Error, fs, path::PathBuf};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let json = serde_json::to_string_pretty(&task_pilot_backend::api::openapi())?;
|
||||
|
||||
if let Some(path) = env::args().nth(1) {
|
||||
if let Some(parent) = PathBuf::from(&path).parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, format!("{json}\n"))?;
|
||||
} else {
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use croner::Cron;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
const MAX_PREVIEW_RUNS: usize = 100;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CronPreviewError {
|
||||
#[error("invalid cron expression: {0}")]
|
||||
InvalidExpression(String),
|
||||
#[error("cron preview can return at most {MAX_PREVIEW_RUNS} runs")]
|
||||
TooManyRuns,
|
||||
#[error("cron expression has no future occurrence")]
|
||||
NoFutureOccurrence,
|
||||
#[error("invalid IANA timezone: {0}")]
|
||||
InvalidTimezone(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CronPreviewRequest {
|
||||
pub expression: String,
|
||||
pub timezone: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct CronPreviewResponse {
|
||||
pub timezone: String,
|
||||
pub runs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Calculates the next occurrences of a cron expression in local time.
|
||||
pub fn next_occurrences(
|
||||
expression: &str,
|
||||
from: DateTime<Local>,
|
||||
count: usize,
|
||||
) -> Result<Vec<DateTime<Local>>, CronPreviewError> {
|
||||
if count > MAX_PREVIEW_RUNS {
|
||||
return Err(CronPreviewError::TooManyRuns);
|
||||
}
|
||||
|
||||
let cron = Cron::from_str(expression)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
let mut runs = Vec::with_capacity(count);
|
||||
let mut cursor = from;
|
||||
|
||||
for _ in 0..count {
|
||||
let next = cron
|
||||
.find_next_occurrence(&cursor, false)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
runs.push(next);
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub fn preview_in_timezone(
|
||||
expression: &str,
|
||||
timezone: &str,
|
||||
from: DateTime<Utc>,
|
||||
count: usize,
|
||||
) -> Result<CronPreviewResponse, CronPreviewError> {
|
||||
if count > MAX_PREVIEW_RUNS {
|
||||
return Err(CronPreviewError::TooManyRuns);
|
||||
}
|
||||
let timezone: Tz = timezone
|
||||
.parse()
|
||||
.map_err(|_| CronPreviewError::InvalidTimezone(timezone.to_owned()))?;
|
||||
let cron = Cron::from_str(expression)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
let mut cursor = from.with_timezone(&timezone);
|
||||
let mut runs = Vec::with_capacity(count);
|
||||
|
||||
for _ in 0..count {
|
||||
let next = cron
|
||||
.find_next_occurrence(&cursor, false)
|
||||
.map_err(|error| CronPreviewError::InvalidExpression(error.to_string()))?;
|
||||
runs.push(next.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok(CronPreviewResponse {
|
||||
timezone: timezone.name().to_owned(),
|
||||
runs,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{Datelike, TimeZone, Weekday};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn local_time(
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
hour: u32,
|
||||
minute: u32,
|
||||
second: u32,
|
||||
) -> DateTime<Local> {
|
||||
Local
|
||||
.with_ymd_and_hms(year, month, day, hour, minute, second)
|
||||
.single()
|
||||
.expect("test timestamp should be unambiguous")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_five_field_expression() {
|
||||
let runs = next_occurrences("0 9 * * *", local_time(2026, 1, 1, 8, 59, 0), 1)
|
||||
.expect("five field expression should parse");
|
||||
|
||||
assert_eq!(runs, vec![local_time(2026, 1, 1, 9, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_six_field_expression_with_seconds() {
|
||||
let runs = next_occurrences("*/15 * * * * *", local_time(2026, 1, 1, 12, 0, 1), 2)
|
||||
.expect("six field expression should parse");
|
||||
|
||||
assert_eq!(
|
||||
runs,
|
||||
vec![
|
||||
local_time(2026, 1, 1, 12, 0, 15),
|
||||
local_time(2026, 1, 1, 12, 0, 30)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_hourly_alias() {
|
||||
let runs = next_occurrences("@hourly", local_time(2026, 1, 1, 12, 34, 56), 1)
|
||||
.expect("hourly alias should parse");
|
||||
|
||||
assert_eq!(runs, vec![local_time(2026, 1, 1, 13, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_step() {
|
||||
let error = next_occurrences("*/0 * * * * *", local_time(2026, 1, 1, 0, 0, 0), 1)
|
||||
.expect_err("zero step should be rejected");
|
||||
|
||||
assert!(matches!(error, CronPreviewError::InvalidExpression(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_posix_or_semantics_for_day_of_month_and_weekday() {
|
||||
let runs = next_occurrences("0 0 0 1 * MON", local_time(2026, 1, 2, 0, 0, 0), 1)
|
||||
.expect("POSIX cron expression should parse");
|
||||
|
||||
assert_eq!(runs[0].day(), 5);
|
||||
assert_eq!(runs[0].weekday(), Weekday::Mon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_explicit_day_and_weekday_and_semantics() {
|
||||
let runs = next_occurrences("0 0 0 1 * +MON", local_time(2026, 1, 2, 0, 0, 0), 1)
|
||||
.expect("AND semantics should parse");
|
||||
|
||||
assert_eq!(runs[0], local_time(2026, 6, 1, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_runs_in_iana_timezone() {
|
||||
let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
|
||||
let preview = preview_in_timezone("0 0 2 * * *", "Asia/Shanghai", from, 2).unwrap();
|
||||
|
||||
assert_eq!(preview.timezone, "Asia/Shanghai");
|
||||
assert_eq!(
|
||||
preview.runs,
|
||||
vec![
|
||||
"2026-01-02 02:00:00".to_owned(),
|
||||
"2026-01-03 02:00:00".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::{error::Error, str::FromStr};
|
||||
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
migrate::MigrateError,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
pub async fn connect(database_url: &str) -> Result<SqlitePool, Box<dyn Error>> {
|
||||
let options = SqliteConnectOptions::from_str(database_url)?
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let max_connections = if database_url == "sqlite::memory:" {
|
||||
1
|
||||
} else {
|
||||
5
|
||||
};
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
migrate(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn migrate(pool: &SqlitePool) -> Result<(), MigrateError> {
|
||||
sqlx::migrate!("./migrations").run(pool).await
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{FromRequest, FromRequestParts, Query, Request, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
pub struct ApiJson<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for ApiJson<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
Json::<T>::from_request(request, state)
|
||||
.await
|
||||
.map(|Json(value)| Self(value))
|
||||
.map_err(json_rejection)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiQuery<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for ApiQuery<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
Query::<T>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map(|Query(value)| Self(value))
|
||||
.map_err(|error| {
|
||||
ApiError::bad_request("INVALID_QUERY", format!("查询参数无效: {error}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
code: &'static str,
|
||||
message: String,
|
||||
field: Option<String>,
|
||||
details: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub field: Option<String>,
|
||||
pub details: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn bad_request(code: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
code,
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
code: "VALIDATION_ERROR",
|
||||
message: message.into(),
|
||||
field: Some(field.into()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "TASK_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "RUN_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_not_cancellable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "RUN_NOT_CANCELLABLE",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_log_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "RUN_LOG_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
code: "FORBIDDEN",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "TASK_ID_CONFLICT",
|
||||
message: message.into(),
|
||||
field: Some("id".to_owned()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unsupported(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNPROCESSABLE_ENTITY,
|
||||
code: "ACTION_NOT_IMPLEMENTED",
|
||||
message: message.into(),
|
||||
field: Some("action.type".to_owned()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(error: impl std::fmt::Display) -> Self {
|
||||
tracing::error!(error = %error, "internal API error");
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "服务器处理请求时发生内部错误".to_owned(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_rejection(error: JsonRejection) -> ApiError {
|
||||
ApiError::bad_request(
|
||||
"INVALID_JSON",
|
||||
format!("请求体不是有效的任务 JSON: {error}"),
|
||||
)
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let body = ErrorResponse {
|
||||
code: self.code.to_owned(),
|
||||
message: self.message,
|
||||
field: self.field,
|
||||
details: self.details,
|
||||
};
|
||||
|
||||
(self.status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
Self::internal(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ApiError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::internal(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
@@ -0,0 +1,40 @@
|
||||
pub mod api;
|
||||
pub mod cron;
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod process_job;
|
||||
pub mod runs;
|
||||
pub mod scheduler;
|
||||
pub mod security;
|
||||
pub mod state;
|
||||
pub mod tasks;
|
||||
|
||||
use axum::Router;
|
||||
use sqlx::SqlitePool;
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
pub async fn app(
|
||||
pool: SqlitePool,
|
||||
logs_dir: std::path::PathBuf,
|
||||
) -> crate::error::ApiResult<Router> {
|
||||
let state = state::AppState::start(pool, logs_dir).await?;
|
||||
Ok(router(state))
|
||||
}
|
||||
|
||||
pub fn router(state: state::AppState) -> Router {
|
||||
api::router(state).layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
pub fn router_with_static(state: state::AppState, static_dir: std::path::PathBuf) -> Router {
|
||||
let index = static_dir.join("index.html");
|
||||
let router = if index.is_file() {
|
||||
api::router(state)
|
||||
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index)))
|
||||
} else {
|
||||
api::router(state)
|
||||
};
|
||||
router.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
fs,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
init_tracing();
|
||||
|
||||
let port = env::var("TASK_PILOT_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(3000);
|
||||
let address = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let database_url = database_url()?;
|
||||
let pool = task_pilot_backend::database::connect(&database_url).await?;
|
||||
let logs_dir = env::var("TASK_PILOT_LOG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("logs").to_path_buf());
|
||||
fs::create_dir_all(&logs_dir)?;
|
||||
let state = task_pilot_backend::state::AppState::start(pool.clone(), logs_dir).await?;
|
||||
let static_dir = env::var("TASK_PILOT_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("frontend/dist").to_path_buf());
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
|
||||
tracing::info!(%address, database_url, "TaskPilot backend listening");
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
task_pilot_backend::router_with_static(state.clone(), static_dir),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
state.shutdown();
|
||||
pool.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn database_url() -> Result<String, Box<dyn Error>> {
|
||||
if let Ok(url) = env::var("TASK_PILOT_DATABASE_URL") {
|
||||
return Ok(url);
|
||||
}
|
||||
|
||||
let data_dir = Path::new("data");
|
||||
fs::create_dir_all(data_dir)?;
|
||||
Ok(format!(
|
||||
"sqlite://{}",
|
||||
data_dir
|
||||
.join("task_pilot.db")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
))
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "task_pilot_backend=debug,tower_http=debug".into());
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
tracing::error!(%error, "failed to listen for shutdown signal");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#[cfg(windows)]
|
||||
mod platform {
|
||||
use std::{
|
||||
ffi::c_void,
|
||||
io,
|
||||
mem::{size_of, zeroed},
|
||||
ptr,
|
||||
};
|
||||
|
||||
use tokio::process::Child;
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{CloseHandle, HANDLE},
|
||||
System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct ProcessJob {
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
// Windows job handles can be used across threads. This wrapper owns exactly one
|
||||
// handle and closes it once in Drop.
|
||||
unsafe impl Send for ProcessJob {}
|
||||
unsafe impl Sync for ProcessJob {}
|
||||
|
||||
impl ProcessJob {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let handle = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let job = Self { handle };
|
||||
job.enable_kill_on_close()?;
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
pub fn assign(&self, child: &Child) -> io::Result<()> {
|
||||
let process_handle = child
|
||||
.raw_handle()
|
||||
.ok_or_else(|| io::Error::other("process handle is unavailable"))?;
|
||||
let result = unsafe { AssignProcessToJobObject(self.handle, process_handle.cast()) };
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn terminate(&self) -> io::Result<()> {
|
||||
let result = unsafe { TerminateJobObject(self.handle, 1) };
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enable_kill_on_close(&self) -> io::Result<()> {
|
||||
let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
let result = unsafe {
|
||||
SetInformationJobObject(
|
||||
self.handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast::<c_void>(),
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessJob {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod platform {
|
||||
use std::io;
|
||||
|
||||
use tokio::process::Child;
|
||||
|
||||
pub struct ProcessJob;
|
||||
|
||||
impl ProcessJob {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn assign(&self, _child: &Child) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn terminate(&self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use platform::ProcessJob;
|
||||
@@ -0,0 +1,962 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::{Instant, SystemTime},
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader},
|
||||
process::{Child, Command},
|
||||
sync::{Mutex as AsyncMutex, broadcast},
|
||||
time::{Duration, sleep, timeout},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
process_job::ProcessJob,
|
||||
runs::{
|
||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||
repository,
|
||||
},
|
||||
tasks::model::{HttpMethod, TaskAction, TaskResponse},
|
||||
};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
const MAX_HTTP_LOG_BODY_BYTES: usize = 64 * 1024;
|
||||
const TERMINATION_WAIT_SECS: u64 = 5;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_RETENTION_DAYS: u64 = 30;
|
||||
const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LogRetentionConfig {
|
||||
pub retention_days: u64,
|
||||
pub max_total_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for LogRetentionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
retention_days: DEFAULT_RETENTION_DAYS,
|
||||
max_total_bytes: DEFAULT_MAX_TOTAL_LOG_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||
pub enum ExecutionEvent {
|
||||
Run {
|
||||
run: TaskRunResponse,
|
||||
},
|
||||
Log {
|
||||
run_id: i64,
|
||||
offset: u64,
|
||||
next_offset: u64,
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExecutionEvent {
|
||||
pub const fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Run { .. } => "run",
|
||||
Self::Log { .. } => "log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TaskExecutor {
|
||||
inner: Arc<ExecutorInner>,
|
||||
}
|
||||
|
||||
struct ExecutorInner {
|
||||
pool: SqlitePool,
|
||||
logs_dir: PathBuf,
|
||||
shutdown: CancellationToken,
|
||||
running: Mutex<HashMap<String, RunningHandle>>,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
http: reqwest::Client,
|
||||
log_config: LogRetentionConfig,
|
||||
cleanup_lock: AsyncMutex<()>,
|
||||
}
|
||||
|
||||
struct RunningHandle {
|
||||
run_id: Option<i64>,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogSink {
|
||||
file: Arc<AsyncMutex<tokio::fs::File>>,
|
||||
size: Arc<AtomicU64>,
|
||||
truncated: Arc<AtomicBool>,
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Arc<Vec<String>>,
|
||||
}
|
||||
|
||||
struct ScriptOutcome {
|
||||
status: RunStatus,
|
||||
exit_code: Option<i32>,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
enum ProcessEnd {
|
||||
Exited(std::process::ExitStatus),
|
||||
TimedOut,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl TaskExecutor {
|
||||
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
|
||||
let (events, _) = broadcast::channel(512);
|
||||
Self {
|
||||
inner: Arc::new(ExecutorInner {
|
||||
pool,
|
||||
logs_dir,
|
||||
shutdown: CancellationToken::new(),
|
||||
running: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
http: reqwest::Client::builder()
|
||||
.user_agent("TaskPilot/0.1")
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
log_config,
|
||||
cleanup_lock: AsyncMutex::new(()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trigger(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
trigger: RunTrigger,
|
||||
) -> ApiResult<TaskRunResponse> {
|
||||
let cancellation = CancellationToken::new();
|
||||
let already_running = {
|
||||
let mut running = lock_running(&self.inner.running);
|
||||
if running.contains_key(&task.id) {
|
||||
true
|
||||
} else {
|
||||
running.insert(
|
||||
task.id.clone(),
|
||||
RunningHandle {
|
||||
run_id: None,
|
||||
cancellation: cancellation.clone(),
|
||||
},
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
if already_running {
|
||||
let run = repository::create_skipped(
|
||||
&self.inner.pool,
|
||||
&task.id,
|
||||
trigger,
|
||||
"同一任务已有运行实例,本次触发已跳过",
|
||||
)
|
||||
.await?;
|
||||
self.emit_run(&run);
|
||||
return Ok(run);
|
||||
}
|
||||
|
||||
let run = match repository::create_queued(&self.inner.pool, &task.id, trigger).await {
|
||||
Ok(run) => run,
|
||||
Err(error) => {
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(handle) = lock_running(&self.inner.running).get_mut(&task.id) {
|
||||
handle.run_id = Some(run.id);
|
||||
}
|
||||
self.emit_run(&run);
|
||||
|
||||
let executor = self.clone();
|
||||
let task = task.clone();
|
||||
tokio::spawn(async move {
|
||||
executor.execute_run(task, run.id, cancellation).await;
|
||||
});
|
||||
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
pub fn cancel_task(&self, task_id: &str) -> bool {
|
||||
let mut running = lock_running(&self.inner.running);
|
||||
if let Some(handle) = running.remove(task_id) {
|
||||
handle.cancellation.cancel();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_run(&self, run_id: i64) -> bool {
|
||||
let mut running = lock_running(&self.inner.running);
|
||||
let task_id = running
|
||||
.iter()
|
||||
.find_map(|(task_id, handle)| (handle.run_id == Some(run_id)).then(|| task_id.clone()));
|
||||
if let Some(task_id) = task_id
|
||||
&& let Some(handle) = running.remove(&task_id)
|
||||
{
|
||||
handle.cancellation.cancel();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<ExecutionEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
pub fn log_path(&self, task_id: &str, run_id: i64) -> PathBuf {
|
||||
self.inner
|
||||
.logs_dir
|
||||
.join(task_id)
|
||||
.join(format!("{run_id}.log"))
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.inner.shutdown.cancel();
|
||||
let running = lock_running(&self.inner.running);
|
||||
for handle in running.values() {
|
||||
handle.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cleanup_logs(&self) -> std::io::Result<()> {
|
||||
let _guard = self.inner.cleanup_lock.lock().await;
|
||||
let logs_dir = self.inner.logs_dir.clone();
|
||||
let config = self.inner.log_config.clone();
|
||||
tokio::task::spawn_blocking(move || cleanup_logs_sync(&logs_dir, &config))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
async fn execute_run(&self, task: TaskResponse, run_id: i64, cancellation: CancellationToken) {
|
||||
let started_at = Utc::now();
|
||||
let started = Instant::now();
|
||||
if let Err(error) = repository::mark_running(&self.inner.pool, run_id, started_at).await {
|
||||
tracing::error!(run_id, error = %error, "failed to mark task run as running");
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
return;
|
||||
}
|
||||
self.emit_run_by_id(run_id).await;
|
||||
|
||||
let outcome = self.run_action(&task, run_id, &cancellation).await;
|
||||
let finished_at = Utc::now();
|
||||
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
if let Err(error) = repository::complete(
|
||||
&self.inner.pool,
|
||||
run_id,
|
||||
outcome.status,
|
||||
finished_at,
|
||||
duration_ms,
|
||||
outcome.exit_code,
|
||||
outcome.error_message.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(run_id, error = %error, "failed to complete task run");
|
||||
}
|
||||
|
||||
self.emit_run_by_id(run_id).await;
|
||||
lock_running(&self.inner.running).remove(&task.id);
|
||||
if let Err(error) = self.cleanup_logs().await {
|
||||
tracing::warn!(%error, "failed to clean task logs");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_action(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
cancellation: &CancellationToken,
|
||||
) -> ScriptOutcome {
|
||||
match &task.action {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let mut command = Command::new(interpreter);
|
||||
command
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.envs(env)
|
||||
.current_dir(working_dir);
|
||||
self.run_command(task, run_id, cancellation, command).await
|
||||
}
|
||||
TaskAction::Process {
|
||||
executable,
|
||||
working_dir,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let mut command = Command::new(executable);
|
||||
command.args(args).envs(env);
|
||||
if let Some(working_dir) = working_dir {
|
||||
command.current_dir(working_dir);
|
||||
}
|
||||
self.run_command(task, run_id, cancellation, command).await
|
||||
}
|
||||
TaskAction::Http { .. } => self.run_http(task, run_id, cancellation).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
cancellation: &CancellationToken,
|
||||
mut command: Command,
|
||||
) -> ScriptOutcome {
|
||||
let log_path = self.log_path(&task.id, run_id);
|
||||
let secrets = sensitive_values(&task.action);
|
||||
let sink = match LogSink::create(
|
||||
&log_path,
|
||||
task,
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
secrets,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sink) => sink,
|
||||
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
|
||||
};
|
||||
|
||||
let job = match ProcessJob::new() {
|
||||
Ok(job) => job,
|
||||
Err(error) => {
|
||||
sink.system(&format!("无法创建 Windows Job Object: {error}"))
|
||||
.await;
|
||||
return failed_outcome(format!("无法创建 Windows Job Object: {error}"));
|
||||
}
|
||||
};
|
||||
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
sink.system(&format!("启动进程失败: {error}")).await;
|
||||
return failed_outcome(format!("启动进程失败: {error}"));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = job.assign(&child) {
|
||||
let _ = child.kill().await;
|
||||
sink.system(&format!("关联 Job Object 失败: {error}")).await;
|
||||
return failed_outcome(format!("关联 Windows Job Object 失败: {error}"));
|
||||
}
|
||||
|
||||
let stdout_task = child
|
||||
.stdout
|
||||
.take()
|
||||
.map(|stdout| spawn_log_reader(stdout, "stdout", sink.clone()));
|
||||
let stderr_task = child
|
||||
.stderr
|
||||
.take()
|
||||
.map(|stderr| spawn_log_reader(stderr, "stderr", sink.clone()));
|
||||
|
||||
let process_end = tokio::select! {
|
||||
status = child.wait() => {
|
||||
match status {
|
||||
Ok(status) => ProcessEnd::Exited(status),
|
||||
Err(error) => {
|
||||
sink.system(&format!("等待进程失败: {error}")).await;
|
||||
return failed_outcome(format!("等待进程失败: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = sleep(Duration::from_secs(task.timeout_secs)) => {
|
||||
if let Err(error) = job.terminate() {
|
||||
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate timed out job");
|
||||
}
|
||||
wait_after_termination(&mut child).await;
|
||||
ProcessEnd::TimedOut
|
||||
}
|
||||
_ = cancellation.cancelled() => {
|
||||
if let Err(error) = job.terminate() {
|
||||
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate cancelled job");
|
||||
}
|
||||
wait_after_termination(&mut child).await;
|
||||
ProcessEnd::Cancelled
|
||||
}
|
||||
_ = self.inner.shutdown.cancelled() => {
|
||||
if let Err(error) = job.terminate() {
|
||||
tracing::error!(task_id = %task.id, run_id, %error, "failed to terminate job during shutdown");
|
||||
}
|
||||
wait_after_termination(&mut child).await;
|
||||
ProcessEnd::Cancelled
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(stdout_task) = stdout_task
|
||||
&& let Err(error) = stdout_task.await
|
||||
{
|
||||
tracing::warn!(task_id = %task.id, run_id, %error, "stdout capture task failed");
|
||||
}
|
||||
if let Some(stderr_task) = stderr_task
|
||||
&& let Err(error) = stderr_task.await
|
||||
{
|
||||
tracing::warn!(task_id = %task.id, run_id, %error, "stderr capture task failed");
|
||||
}
|
||||
|
||||
match process_end {
|
||||
ProcessEnd::Exited(status) => {
|
||||
let exit_code = status.code();
|
||||
if status.success() {
|
||||
sink.system("任务执行成功").await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Succeeded,
|
||||
exit_code,
|
||||
error_message: None,
|
||||
}
|
||||
} else {
|
||||
let message = format!(
|
||||
"进程退出码为 {}",
|
||||
exit_code.map_or_else(|| "unknown".to_owned(), |code| code.to_string())
|
||||
);
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Failed,
|
||||
exit_code,
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
ProcessEnd::TimedOut => {
|
||||
let message = format!("任务执行超过 {} 秒,已终止进程树", task.timeout_secs);
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::TimedOut,
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
ProcessEnd::Cancelled => {
|
||||
let message = "任务已取消".to_owned();
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_http(
|
||||
&self,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
cancellation: &CancellationToken,
|
||||
) -> ScriptOutcome {
|
||||
let TaskAction::Http {
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
expected_status,
|
||||
} = &task.action
|
||||
else {
|
||||
return failed_outcome("invalid HTTP action payload");
|
||||
};
|
||||
|
||||
let log_path = self.log_path(&task.id, run_id);
|
||||
let sink = match LogSink::create(
|
||||
&log_path,
|
||||
task,
|
||||
run_id,
|
||||
self.inner.events.clone(),
|
||||
sensitive_values(&task.action),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sink) => sink,
|
||||
Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")),
|
||||
};
|
||||
|
||||
let method = match method {
|
||||
HttpMethod::Get => Method::GET,
|
||||
HttpMethod::Post => Method::POST,
|
||||
};
|
||||
let mut request = self
|
||||
.inner
|
||||
.http
|
||||
.request(method.clone(), url)
|
||||
.timeout(Duration::from_secs(task.timeout_secs));
|
||||
for (name, value) in headers {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(body) = body {
|
||||
request = request.body(body.clone());
|
||||
}
|
||||
|
||||
sink.http(&format!("请求 {method} {url}")).await;
|
||||
let response = tokio::select! {
|
||||
response = request.send() => {
|
||||
match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let message = if error.is_timeout() {
|
||||
format!("HTTP 请求超过 {} 秒", task.timeout_secs)
|
||||
} else {
|
||||
format!("HTTP 请求失败: {error}")
|
||||
};
|
||||
sink.system(&message).await;
|
||||
return ScriptOutcome {
|
||||
status: if error.is_timeout() {
|
||||
RunStatus::TimedOut
|
||||
} else {
|
||||
RunStatus::Failed
|
||||
},
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = cancellation.cancelled() => {
|
||||
sink.system("HTTP 请求已取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("HTTP 请求已取消".to_owned()),
|
||||
};
|
||||
}
|
||||
_ = self.inner.shutdown.cancelled() => {
|
||||
sink.system("HTTP 请求因服务关闭而取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("HTTP 请求因服务关闭而取消".to_owned()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let http_status = response.status();
|
||||
sink.http(&format!("响应状态 {http_status}")).await;
|
||||
let mut response_stream = response.bytes_stream();
|
||||
let mut response_body = Vec::new();
|
||||
let mut body_truncated = false;
|
||||
loop {
|
||||
let next = tokio::select! {
|
||||
chunk = response_stream.next() => chunk,
|
||||
_ = cancellation.cancelled() => {
|
||||
sink.system("读取 HTTP 响应已取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("读取 HTTP 响应已取消".to_owned()),
|
||||
};
|
||||
}
|
||||
_ = self.inner.shutdown.cancelled() => {
|
||||
sink.system("HTTP 响应读取因服务关闭而取消").await;
|
||||
return ScriptOutcome {
|
||||
status: RunStatus::Cancelled,
|
||||
exit_code: None,
|
||||
error_message: Some("HTTP 响应读取因服务关闭而取消".to_owned()),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match next {
|
||||
Some(Ok(chunk)) => {
|
||||
let remaining = MAX_HTTP_LOG_BODY_BYTES.saturating_sub(response_body.len());
|
||||
let copy_length = remaining.min(chunk.len());
|
||||
response_body.extend_from_slice(&chunk[..copy_length]);
|
||||
if chunk.len() > copy_length {
|
||||
body_truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
let message = if error.is_timeout() {
|
||||
format!("读取 HTTP 响应超过 {} 秒", task.timeout_secs)
|
||||
} else {
|
||||
format!("读取 HTTP 响应失败: {error}")
|
||||
};
|
||||
sink.system(&message).await;
|
||||
return ScriptOutcome {
|
||||
status: if error.is_timeout() {
|
||||
RunStatus::TimedOut
|
||||
} else {
|
||||
RunStatus::Failed
|
||||
},
|
||||
exit_code: None,
|
||||
error_message: Some(message),
|
||||
};
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let body_text = String::from_utf8_lossy(&response_body);
|
||||
if !body_text.is_empty() {
|
||||
sink.http(&body_text).await;
|
||||
}
|
||||
if body_truncated {
|
||||
sink.system(&format!(
|
||||
"HTTP 响应体超过 {MAX_HTTP_LOG_BODY_BYTES} 字节,日志仅保留前部内容"
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
let expected = if expected_status.is_empty() {
|
||||
http_status.is_success()
|
||||
} else {
|
||||
expected_status.contains(&http_status.as_u16())
|
||||
};
|
||||
if expected {
|
||||
sink.system("HTTP 任务执行成功").await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Succeeded,
|
||||
exit_code: Some(i32::from(http_status.as_u16())),
|
||||
error_message: None,
|
||||
}
|
||||
} else {
|
||||
let message = format!("HTTP 状态码 {http_status} 不在期望范围内");
|
||||
sink.system(&message).await;
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Failed,
|
||||
exit_code: Some(i32::from(http_status.as_u16())),
|
||||
error_message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_run(&self, run: &TaskRunResponse) {
|
||||
let _ = self
|
||||
.inner
|
||||
.events
|
||||
.send(ExecutionEvent::Run { run: run.clone() });
|
||||
}
|
||||
|
||||
async fn emit_run_by_id(&self, run_id: i64) {
|
||||
match repository::get(&self.inner.pool, run_id).await {
|
||||
Ok(Some(run)) => self.emit_run(&run),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(run_id, error = %error, "failed to load run for SSE event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogSink {
|
||||
async fn create(
|
||||
path: &Path,
|
||||
task: &TaskResponse,
|
||||
run_id: i64,
|
||||
events: broadcast::Sender<ExecutionEvent>,
|
||||
secrets: Vec<String>,
|
||||
) -> std::io::Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let header = format!(
|
||||
"[system] TaskPilot run {run_id} for task {} started at {}\n",
|
||||
task.id,
|
||||
Utc::now().to_rfc3339()
|
||||
);
|
||||
tokio::fs::write(path, header.as_bytes()).await?;
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.await?;
|
||||
let mut secrets = secrets;
|
||||
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
|
||||
|
||||
Ok(Self {
|
||||
file: Arc::new(AsyncMutex::new(file)),
|
||||
size: Arc::new(AtomicU64::new(header.len() as u64)),
|
||||
truncated: Arc::new(AtomicBool::new(false)),
|
||||
run_id,
|
||||
events,
|
||||
secrets: Arc::new(secrets),
|
||||
})
|
||||
}
|
||||
|
||||
async fn append(&self, text: &str) {
|
||||
let redacted = redact_text(text, &self.secrets);
|
||||
let bytes = redacted.as_bytes();
|
||||
let current = self.size.load(Ordering::Relaxed);
|
||||
if current + bytes.len() as u64 > MAX_LOG_BYTES {
|
||||
if !self.truncated.swap(true, Ordering::Relaxed) {
|
||||
let notice = format!("[system] 日志超过 {MAX_LOG_BYTES} 字节,后续输出已丢弃\n");
|
||||
let mut file = self.file.lock().await;
|
||||
let _ = file.write_all(notice.as_bytes()).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut file = self.file.lock().await;
|
||||
if file.write_all(bytes).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let next = self.size.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
|
||||
let _ = self.events.send(ExecutionEvent::Log {
|
||||
run_id: self.run_id,
|
||||
offset: current,
|
||||
next_offset: next,
|
||||
content: redacted,
|
||||
});
|
||||
}
|
||||
|
||||
async fn system(&self, message: &str) {
|
||||
self.append(&format!("[system] {message}\n")).await;
|
||||
}
|
||||
|
||||
async fn http(&self, message: &str) {
|
||||
self.append(&format!("[http] {message}\n")).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_running(
|
||||
running: &Mutex<HashMap<String, RunningHandle>>,
|
||||
) -> std::sync::MutexGuard<'_, HashMap<String, RunningHandle>> {
|
||||
running
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn spawn_log_reader<R>(
|
||||
reader: R,
|
||||
stream: &'static str,
|
||||
sink: LogSink,
|
||||
) -> tokio::task::JoinHandle<()>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let text = String::from_utf8_lossy(&line);
|
||||
sink.append(&format!("[{stream}] {text}")).await;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(stream, %error, "failed to read task output");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_after_termination(child: &mut Child) {
|
||||
if timeout(Duration::from_secs(TERMINATION_WAIT_SECS), child.wait())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
||||
match action {
|
||||
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
||||
.iter()
|
||||
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
|
||||
.map(|(_, value)| value.clone())
|
||||
.collect(),
|
||||
TaskAction::Http { headers, .. } => headers
|
||||
.iter()
|
||||
.filter(|(key, value)| is_sensitive_name(key) && !value.is_empty())
|
||||
.map(|(_, value)| value.clone())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_sensitive_name(name: &str) -> bool {
|
||||
let name = name.to_ascii_uppercase();
|
||||
[
|
||||
"SECRET",
|
||||
"TOKEN",
|
||||
"PASSWORD",
|
||||
"PASS",
|
||||
"API_KEY",
|
||||
"PRIVATE_KEY",
|
||||
"CREDENTIAL",
|
||||
"AUTHORIZATION",
|
||||
"AUTH",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
}
|
||||
|
||||
fn redact_text(text: &str, secrets: &[String]) -> String {
|
||||
secrets.iter().fold(text.to_owned(), |current, secret| {
|
||||
current.replace(secret, "[REDACTED]")
|
||||
})
|
||||
}
|
||||
|
||||
fn failed_outcome(message: impl Into<String>) -> ScriptOutcome {
|
||||
ScriptOutcome {
|
||||
status: RunStatus::Failed,
|
||||
exit_code: None,
|
||||
error_message: Some(message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_logs_sync(root: &Path, config: &LogRetentionConfig) -> std::io::Result<()> {
|
||||
if !root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_log_files(root, &mut files)?;
|
||||
let retention = Duration::from_secs(config.retention_days.saturating_mul(86_400));
|
||||
let cutoff = SystemTime::now().checked_sub(retention);
|
||||
|
||||
if let Some(cutoff) = cutoff {
|
||||
files.retain(|(path, metadata)| {
|
||||
let expired = metadata
|
||||
.modified()
|
||||
.map(|modified| modified < cutoff)
|
||||
.unwrap_or(false);
|
||||
if expired {
|
||||
let _ = std::fs::remove_file(path);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
files.sort_by_key(|(_, metadata)| metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH));
|
||||
let mut total = files
|
||||
.iter()
|
||||
.map(|(_, metadata)| metadata.len())
|
||||
.sum::<u64>();
|
||||
for (path, metadata) in files {
|
||||
if total <= config.max_total_bytes {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
total = total.saturating_sub(metadata.len());
|
||||
}
|
||||
}
|
||||
|
||||
remove_empty_directories(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_log_files(
|
||||
directory: &Path,
|
||||
files: &mut Vec<(PathBuf, std::fs::Metadata)>,
|
||||
) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
collect_log_files(&entry.path(), files)?;
|
||||
} else if file_type.is_file() {
|
||||
files.push((entry.path(), entry.metadata()?));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_empty_directories(root: &Path) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
let path = entry.path();
|
||||
remove_empty_directories(&path)?;
|
||||
if std::fs::read_dir(&path)?.next().is_none() {
|
||||
let _ = std::fs::remove_dir(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn terminal_statuses_are_identified() {
|
||||
assert!(!RunStatus::Queued.is_terminal());
|
||||
assert!(!RunStatus::Running.is_terminal());
|
||||
assert!(RunStatus::Succeeded.is_terminal());
|
||||
assert!(RunStatus::Failed.is_terminal());
|
||||
assert!(RunStatus::TimedOut.is_terminal());
|
||||
assert!(RunStatus::Cancelled.is_terminal());
|
||||
assert!(RunStatus::Skipped.is_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_values() {
|
||||
let secrets = vec!["super-secret".to_owned(), "token-value".to_owned()];
|
||||
let output = redact_text("found super-secret and token-value", &secrets);
|
||||
|
||||
assert_eq!(output, "found [REDACTED] and [REDACTED]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifies_sensitive_environment_names() {
|
||||
assert!(is_sensitive_name("API_TOKEN"));
|
||||
assert!(is_sensitive_name("DB_PASSWORD"));
|
||||
assert!(is_sensitive_name("Authorization"));
|
||||
assert!(!is_sensitive_name("PYTHONUNBUFFERED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_enforces_total_log_capacity() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
for index in 0..6 {
|
||||
std::fs::write(temp.path().join(format!("run-{index}.log")), vec![b'x'; 4]).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
cleanup_logs_sync(
|
||||
temp.path(),
|
||||
&LogRetentionConfig {
|
||||
retention_days: 30,
|
||||
max_total_bytes: 10,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let total = std::fs::read_dir(temp.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().metadata().unwrap().len())
|
||||
.sum::<u64>();
|
||||
assert!(total <= 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod executor;
|
||||
pub mod model;
|
||||
pub mod repository;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,98 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunTrigger {
|
||||
Schedule,
|
||||
Manual,
|
||||
Retry,
|
||||
}
|
||||
|
||||
impl RunTrigger {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Schedule => "schedule",
|
||||
Self::Manual => "manual",
|
||||
Self::Retry => "retry",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
TimedOut,
|
||||
Cancelled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl RunStatus {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Queued => "queued",
|
||||
Self::Running => "running",
|
||||
Self::Succeeded => "succeeded",
|
||||
Self::Failed => "failed",
|
||||
Self::TimedOut => "timed_out",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_terminal(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded | Self::Failed | Self::TimedOut | Self::Cancelled | Self::Skipped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct TaskRunResponse {
|
||||
pub id: i64,
|
||||
pub task_id: String,
|
||||
pub trigger: RunTrigger,
|
||||
pub status: RunStatus,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TaskRunListResponse {
|
||||
pub items: Vec<TaskRunResponse>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct RunListQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
pub status: Option<RunStatus>,
|
||||
pub trigger: Option<RunTrigger>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct LogChunkQuery {
|
||||
pub offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LogChunkResponse {
|
||||
pub content: String,
|
||||
pub next_offset: u64,
|
||||
pub complete: bool,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
runs::model::{RunListQuery, RunStatus, RunTrigger, TaskRunListResponse, TaskRunResponse},
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 20;
|
||||
const MAX_PAGE_SIZE: u32 = 100;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct TaskRunRow {
|
||||
id: i64,
|
||||
task_id: String,
|
||||
trigger_type: String,
|
||||
status: String,
|
||||
started_at: Option<DateTime<Utc>>,
|
||||
finished_at: Option<DateTime<Utc>>,
|
||||
duration_ms: Option<i64>,
|
||||
exit_code: Option<i64>,
|
||||
error_message: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<TaskRunRow> for TaskRunResponse {
|
||||
type Error = ApiError;
|
||||
|
||||
fn try_from(row: TaskRunRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
task_id: row.task_id,
|
||||
trigger: parse_trigger(&row.trigger_type)?,
|
||||
status: parse_status(&row.status)?,
|
||||
started_at: row.started_at,
|
||||
finished_at: row.finished_at,
|
||||
duration_ms: row
|
||||
.duration_ms
|
||||
.map(u64::try_from)
|
||||
.transpose()
|
||||
.map_err(ApiError::internal)?,
|
||||
exit_code: row
|
||||
.exit_code
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(ApiError::internal)?,
|
||||
error_message: row.error_message,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_queued(
|
||||
pool: &SqlitePool,
|
||||
task_id: &str,
|
||||
trigger: RunTrigger,
|
||||
) -> ApiResult<TaskRunResponse> {
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO task_runs (
|
||||
task_id, trigger_type, status, created_at
|
||||
) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(trigger.as_str())
|
||||
.bind(RunStatus::Queued.as_str())
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(TaskRunResponse {
|
||||
id: result.last_insert_rowid(),
|
||||
task_id: task_id.to_owned(),
|
||||
trigger,
|
||||
status: RunStatus::Queued,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
duration_ms: None,
|
||||
exit_code: None,
|
||||
error_message: None,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_skipped(
|
||||
pool: &SqlitePool,
|
||||
task_id: &str,
|
||||
trigger: RunTrigger,
|
||||
message: &str,
|
||||
) -> ApiResult<TaskRunResponse> {
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO task_runs (
|
||||
task_id, trigger_type, status, started_at, finished_at,
|
||||
duration_ms, error_message, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(trigger.as_str())
|
||||
.bind(RunStatus::Skipped.as_str())
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(message)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(TaskRunResponse {
|
||||
id: result.last_insert_rowid(),
|
||||
task_id: task_id.to_owned(),
|
||||
trigger,
|
||||
status: RunStatus::Skipped,
|
||||
started_at: Some(now),
|
||||
finished_at: Some(now),
|
||||
duration_ms: Some(0),
|
||||
exit_code: None,
|
||||
error_message: Some(message.to_owned()),
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mark_running(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
started_at: DateTime<Utc>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query("UPDATE task_runs SET status = ?, started_at = ? WHERE id = ?")
|
||||
.bind(RunStatus::Running.as_str())
|
||||
.bind(started_at)
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
pool: &SqlitePool,
|
||||
run_id: i64,
|
||||
status: RunStatus,
|
||||
finished_at: DateTime<Utc>,
|
||||
duration_ms: u64,
|
||||
exit_code: Option<i32>,
|
||||
error_message: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE task_runs SET
|
||||
status = ?, finished_at = ?, duration_ms = ?,
|
||||
exit_code = ?, error_message = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(status.as_str())
|
||||
.bind(finished_at)
|
||||
.bind(duration_ms as i64)
|
||||
.bind(exit_code.map(i64::from))
|
||||
.bind(error_message)
|
||||
.bind(run_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, run_id: i64) -> ApiResult<Option<TaskRunResponse>> {
|
||||
let row = sqlx::query_as::<_, TaskRunRow>(
|
||||
"SELECT
|
||||
id, task_id, trigger_type, status, started_at, finished_at,
|
||||
duration_ms, exit_code, error_message, created_at
|
||||
FROM task_runs
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(run_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(TaskRunResponse::try_from).transpose()
|
||||
}
|
||||
|
||||
pub async fn list_for_task(
|
||||
pool: &SqlitePool,
|
||||
task_id: &str,
|
||||
query: RunListQuery,
|
||||
) -> ApiResult<TaskRunListResponse> {
|
||||
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 offset = i64::from(page - 1) * i64::from(page_size);
|
||||
let status = query.status.map(RunStatus::as_str);
|
||||
let trigger = query.trigger.map(RunTrigger::as_str);
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM task_runs
|
||||
WHERE task_id = ?
|
||||
AND (? IS NULL OR status = ?)
|
||||
AND (? IS NULL OR trigger_type = ?)",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(trigger)
|
||||
.bind(trigger)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let rows = sqlx::query_as::<_, TaskRunRow>(
|
||||
"SELECT
|
||||
id, task_id, trigger_type, status, started_at, finished_at,
|
||||
duration_ms, exit_code, error_message, created_at
|
||||
FROM task_runs
|
||||
WHERE task_id = ?
|
||||
AND (? IS NULL OR status = ?)
|
||||
AND (? IS NULL OR trigger_type = ?)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(trigger)
|
||||
.bind(trigger)
|
||||
.bind(i64::from(page_size))
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(TaskRunResponse::try_from)
|
||||
.collect::<ApiResult<Vec<_>>>()?;
|
||||
|
||||
Ok(TaskRunListResponse {
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total: u64::try_from(total).map_err(ApiError::internal)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_trigger(value: &str) -> ApiResult<RunTrigger> {
|
||||
match value {
|
||||
"schedule" => Ok(RunTrigger::Schedule),
|
||||
"manual" => Ok(RunTrigger::Manual),
|
||||
"retry" => Ok(RunTrigger::Retry),
|
||||
_ => Err(ApiError::internal(format!("unknown run trigger: {value}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status(value: &str) -> ApiResult<RunStatus> {
|
||||
match value {
|
||||
"queued" => Ok(RunStatus::Queued),
|
||||
"running" => Ok(RunStatus::Running),
|
||||
"succeeded" => Ok(RunStatus::Succeeded),
|
||||
"failed" => Ok(RunStatus::Failed),
|
||||
"timed_out" => Ok(RunStatus::TimedOut),
|
||||
"cancelled" => Ok(RunStatus::Cancelled),
|
||||
"skipped" => Ok(RunStatus::Skipped),
|
||||
_ => Err(ApiError::internal(format!("unknown run status: {value}"))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
routing::{get, post},
|
||||
};
|
||||
use futures_util::{StreamExt, stream};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiQuery, ApiResult},
|
||||
runs::{
|
||||
model::{
|
||||
LogChunkQuery, LogChunkResponse, RunListQuery, RunTrigger, TaskRunListResponse,
|
||||
TaskRunResponse,
|
||||
},
|
||||
repository,
|
||||
},
|
||||
state::AppState,
|
||||
tasks::service,
|
||||
};
|
||||
|
||||
const MAX_LOG_CHUNK_BYTES: u64 = 64 * 1024;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/tasks/{id}/run", post(run_task))
|
||||
.route("/api/v1/tasks/{id}/runs", get(list_task_runs))
|
||||
.route("/api/v1/runs/{run_id}", get(get_run))
|
||||
.route("/api/v1/runs/{run_id}/logs", get(get_run_logs))
|
||||
.route(
|
||||
"/api/v1/runs/{run_id}/logs/download",
|
||||
get(download_run_logs),
|
||||
)
|
||||
.route("/api/v1/runs/{run_id}/cancel", post(cancel_run))
|
||||
.route("/api/v1/events", get(execution_events))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks/{id}/run",
|
||||
tag = "runs",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 202, description = "Manual run accepted", body = TaskRunResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse),
|
||||
(status = 422, description = "Action executor not implemented", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn run_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
|
||||
let task = service::get(&state.pool, &id).await?;
|
||||
let run = state.executor.trigger(&task, RunTrigger::Manual).await?;
|
||||
Ok((StatusCode::ACCEPTED, Json(run)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{id}/runs",
|
||||
tag = "runs",
|
||||
params(
|
||||
("id" = String, Path, description = "Stable task identifier"),
|
||||
RunListQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Task run history", body = TaskRunListResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_task_runs(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
ApiQuery(query): ApiQuery<RunListQuery>,
|
||||
) -> ApiResult<Json<TaskRunListResponse>> {
|
||||
Ok(Json(
|
||||
repository::list_for_task(&state.pool, &id, query).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/runs/{run_id}",
|
||||
tag = "runs",
|
||||
params(("run_id" = i64, Path, description = "Run identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task run detail", body = TaskRunResponse),
|
||||
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
) -> ApiResult<Json<TaskRunResponse>> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
Ok(Json(run))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/runs/{run_id}/logs",
|
||||
tag = "runs",
|
||||
params(
|
||||
("run_id" = i64, Path, description = "Run identifier"),
|
||||
LogChunkQuery
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Incremental task run log", body = LogChunkResponse),
|
||||
(status = 404, description = "Run not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_run_logs(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
ApiQuery(query): ApiQuery<LogChunkQuery>,
|
||||
) -> ApiResult<Json<LogChunkResponse>> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
let path = state.executor.log_path(&run.task_id, run.id);
|
||||
|
||||
let mut file = match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Json(LogChunkResponse {
|
||||
content: String::new(),
|
||||
next_offset: offset,
|
||||
complete: run.status.is_terminal(),
|
||||
}));
|
||||
}
|
||||
Err(error) => return Err(ApiError::internal(error)),
|
||||
};
|
||||
|
||||
let length = file.metadata().await.map_err(ApiError::internal)?.len();
|
||||
if offset > length {
|
||||
return Err(ApiError::validation(
|
||||
"offset",
|
||||
"日志读取偏移不能大于当前日志长度",
|
||||
));
|
||||
}
|
||||
file.seek(std::io::SeekFrom::Start(offset))
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_LOG_CHUNK_BYTES)
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
|
||||
Ok(Json(LogChunkResponse {
|
||||
content: String::from_utf8_lossy(&bytes).into_owned(),
|
||||
next_offset: offset + bytes.len() as u64,
|
||||
complete: run.status.is_terminal(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/runs/{run_id}/cancel",
|
||||
tag = "runs",
|
||||
params(("run_id" = i64, Path, description = "Run identifier")),
|
||||
responses(
|
||||
(status = 202, description = "Cancellation requested", body = TaskRunResponse),
|
||||
(status = 404, description = "Run not found", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Run is not cancellable", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn cancel_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
) -> ApiResult<(StatusCode, Json<TaskRunResponse>)> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
if run.status.is_terminal() {
|
||||
return Err(ApiError::run_not_cancellable("运行已经结束,无法取消"));
|
||||
}
|
||||
if !state.executor.cancel_run(run_id) {
|
||||
return Err(ApiError::run_not_cancellable(
|
||||
"运行不在当前实例的可取消状态中",
|
||||
));
|
||||
}
|
||||
Ok((StatusCode::ACCEPTED, Json(run)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/runs/{run_id}/logs/download",
|
||||
tag = "runs",
|
||||
params(("run_id" = i64, Path, description = "Run identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Plain text task log", body = String, content_type = "text/plain"),
|
||||
(status = 404, description = "Run or log not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn download_run_logs(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<i64>,
|
||||
) -> ApiResult<Response> {
|
||||
let run = repository::get(&state.pool, run_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::run_not_found(format!("运行记录 {run_id} 不存在")))?;
|
||||
let path = state.executor.log_path(&run.task_id, run.id);
|
||||
let content = tokio::fs::read(&path).await.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
ApiError::run_log_not_found(format!("运行记录 {run_id} 还没有日志文件"))
|
||||
} else {
|
||||
ApiError::internal(error)
|
||||
}
|
||||
})?;
|
||||
let mut response = content.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"run-{run_id}.log\"")
|
||||
.parse::<HeaderValue>()
|
||||
.map_err(ApiError::internal)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/events",
|
||||
tag = "runs",
|
||||
responses(
|
||||
(status = 200, description = "Run status and log events", body = String, content_type = "text/event-stream")
|
||||
)
|
||||
)]
|
||||
pub async fn execution_events(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
let receiver = state.executor.subscribe();
|
||||
let events = BroadcastStream::new(receiver).filter_map(|message| async move {
|
||||
match message {
|
||||
Ok(event) => {
|
||||
let event_name = event.event_name();
|
||||
let data = serde_json::to_string(&event).ok()?;
|
||||
Some(Ok(Event::default().event(event_name).data(data)))
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "SSE subscriber lagged");
|
||||
Some(Ok(Event::default()
|
||||
.event("lagged")
|
||||
.data("{\"reason\":\"event stream lagged\"}")))
|
||||
}
|
||||
}
|
||||
});
|
||||
let events =
|
||||
stream::once(async { Ok(Event::default().event("ready").data("{\"status\":\"ok\"}")) })
|
||||
.chain(events);
|
||||
|
||||
Sse::new(events).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15)))
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use croner::Cron;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
runs::{executor::TaskExecutor, model::RunTrigger},
|
||||
tasks::{model::TaskAction, model::TaskResponse, repository},
|
||||
};
|
||||
|
||||
struct ScheduledTask {
|
||||
cancellation: CancellationToken,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
struct SchedulerInner {
|
||||
executor: TaskExecutor,
|
||||
shutdown: CancellationToken,
|
||||
jobs: Mutex<HashMap<String, ScheduledTask>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Scheduler {
|
||||
inner: Arc<SchedulerInner>,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
pub async fn start(pool: SqlitePool, executor: TaskExecutor) -> ApiResult<Self> {
|
||||
let scheduler = Self {
|
||||
inner: Arc::new(SchedulerInner {
|
||||
executor,
|
||||
shutdown: CancellationToken::new(),
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
};
|
||||
|
||||
for task in repository::list_enabled_script_tasks(&pool).await? {
|
||||
scheduler.refresh(task);
|
||||
}
|
||||
|
||||
Ok(scheduler)
|
||||
}
|
||||
|
||||
pub fn refresh(&self, task: TaskResponse) {
|
||||
self.cancel(&task.id);
|
||||
if !task.enabled || !matches!(task.action, TaskAction::Script { .. }) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancellation = CancellationToken::new();
|
||||
let task_for_loop = task.clone();
|
||||
let executor = self.inner.executor.clone();
|
||||
let shutdown = self.inner.shutdown.clone();
|
||||
let cancellation_for_loop = cancellation.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
schedule_loop(task_for_loop, executor, cancellation_for_loop, shutdown).await;
|
||||
});
|
||||
|
||||
lock_jobs(&self.inner.jobs).insert(
|
||||
task.id,
|
||||
ScheduledTask {
|
||||
cancellation,
|
||||
handle,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn cancel(&self, task_id: &str) {
|
||||
if let Some(job) = lock_jobs(&self.inner.jobs).remove(task_id) {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_scheduled(&self, task_id: &str) -> bool {
|
||||
lock_jobs(&self.inner.jobs).contains_key(task_id)
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.inner.shutdown.cancel();
|
||||
let mut jobs = lock_jobs(&self.inner.jobs);
|
||||
for job in jobs.values() {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
jobs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SchedulerInner {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.cancel();
|
||||
let jobs = self
|
||||
.jobs
|
||||
.get_mut()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
for job in jobs.values() {
|
||||
job.cancellation.cancel();
|
||||
job.handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn schedule_loop(
|
||||
task: TaskResponse,
|
||||
executor: TaskExecutor,
|
||||
cancellation: CancellationToken,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
loop {
|
||||
let now = Utc::now();
|
||||
let next = match next_occurrence(&task, now) {
|
||||
Ok(next) => next,
|
||||
Err(error) => {
|
||||
tracing::error!(task_id = %task.id, %error, "cron schedule calculation failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let delay = (next - now).to_std().unwrap_or_default();
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return,
|
||||
_ = shutdown.cancelled() => return,
|
||||
_ = tokio::time::sleep(delay) => {
|
||||
if let Err(error) = executor.trigger(&task, RunTrigger::Schedule).await {
|
||||
tracing::error!(task_id = %task.id, %error, "scheduled task trigger failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_occurrence(task: &TaskResponse, now: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
|
||||
let timezone = Tz::from_str(&task.timezone)
|
||||
.map_err(|_| format!("invalid task timezone: {}", task.timezone))?;
|
||||
let cron = Cron::from_str(&task.cron).map_err(|error| format!("invalid task cron: {error}"))?;
|
||||
let local_now = now.with_timezone(&timezone);
|
||||
let next = cron
|
||||
.find_next_occurrence(&local_now, false)
|
||||
.map_err(|error| format!("failed to calculate next occurrence: {error}"))?;
|
||||
Ok(next.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn lock_jobs(
|
||||
jobs: &Mutex<HashMap<String, ScheduledTask>>,
|
||||
) -> MutexGuard<'_, HashMap<String, ScheduledTask>> {
|
||||
jobs.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn task(cron: &str, timezone: &str) -> TaskResponse {
|
||||
TaskResponse {
|
||||
id: "schedule_test".to_owned(),
|
||||
name: "Schedule test".to_owned(),
|
||||
cron: cron.to_owned(),
|
||||
timezone: timezone.to_owned(),
|
||||
enabled: true,
|
||||
timeout_secs: 30,
|
||||
concurrency_policy: ConcurrencyPolicy::Skip,
|
||||
retry_policy: RetryPolicy::default(),
|
||||
action: TaskAction::Script {
|
||||
working_dir: ".".to_owned(),
|
||||
interpreter: "cmd.exe".to_owned(),
|
||||
script: "/C".to_owned(),
|
||||
args: vec!["exit".to_owned(), "0".to_owned()],
|
||||
env: Default::default(),
|
||||
},
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculates_next_occurrence_in_task_timezone() {
|
||||
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
|
||||
let next = next_occurrence(&task("0 0 9 * * *", "Asia/Shanghai"), now).unwrap();
|
||||
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap());
|
||||
assert_eq!(next.hour(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_timezone() {
|
||||
let now = Utc::now();
|
||||
let error = next_occurrence(&task("0 * * * * *", "Mars/Olympus"), now).unwrap_err();
|
||||
|
||||
assert!(error.contains("timezone"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, Method, header},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub const AUTH_HEADER: &str = "x-taskpilot-token";
|
||||
|
||||
pub async fn api_guard(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> ApiResult<Response> {
|
||||
let headers = request.headers();
|
||||
if let Some(origin) = headers.get(header::ORIGIN) {
|
||||
let origin = origin.to_str().unwrap_or_default();
|
||||
if !is_local_origin(origin) {
|
||||
return Err(ApiError::forbidden("请求 Origin 不在本地允许列表中"));
|
||||
}
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
*request.method(),
|
||||
Method::GET | Method::HEAD | Method::OPTIONS
|
||||
) && !has_valid_token(headers, state.auth_token())
|
||||
{
|
||||
return Err(ApiError::forbidden("缺少有效的本地管理令牌"));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
fn has_valid_token(headers: &HeaderMap, expected: &str) -> bool {
|
||||
headers
|
||||
.get(AUTH_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.iter()
|
||||
.zip(right)
|
||||
.fold(0_u8, |difference, (left, right)| {
|
||||
difference | (left ^ right)
|
||||
})
|
||||
== 0
|
||||
}
|
||||
|
||||
fn is_local_origin(origin: &str) -> bool {
|
||||
url::Url::parse(origin).is_ok_and(|url| {
|
||||
url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_local_origins_only() {
|
||||
assert!(is_local_origin("http://127.0.0.1:5173"));
|
||||
assert!(is_local_origin("http://localhost:3000"));
|
||||
assert!(!is_local_origin("http://127.0.0.1.example.com"));
|
||||
assert!(!is_local_origin("https://example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
runs::executor::{LogRetentionConfig, TaskExecutor},
|
||||
scheduler::Scheduler,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: SqlitePool,
|
||||
pub scheduler: Scheduler,
|
||||
pub executor: TaskExecutor,
|
||||
auth_token: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn start(pool: SqlitePool, logs_dir: PathBuf) -> ApiResult<Self> {
|
||||
tokio::fs::create_dir_all(&logs_dir)
|
||||
.await
|
||||
.map_err(ApiError::internal)?;
|
||||
let mut log_config = LogRetentionConfig::default();
|
||||
if let Ok(value) = env::var("TASK_PILOT_LOG_RETENTION_DAYS")
|
||||
&& let Ok(days) = value.parse::<u64>()
|
||||
{
|
||||
log_config.retention_days = days;
|
||||
}
|
||||
if let Ok(value) = env::var("TASK_PILOT_LOG_MAX_TOTAL_MB")
|
||||
&& let Ok(megabytes) = value.parse::<u64>()
|
||||
{
|
||||
log_config.max_total_bytes = megabytes.saturating_mul(1024 * 1024);
|
||||
}
|
||||
let executor = TaskExecutor::new(pool.clone(), logs_dir, log_config);
|
||||
executor.cleanup_logs().await.map_err(ApiError::internal)?;
|
||||
let scheduler = Scheduler::start(pool.clone(), executor.clone()).await?;
|
||||
|
||||
Ok(Self {
|
||||
pool,
|
||||
scheduler,
|
||||
executor,
|
||||
auth_token: uuid::Uuid::new_v4().simple().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.scheduler.shutdown();
|
||||
self.executor.shutdown();
|
||||
}
|
||||
|
||||
pub fn auth_token(&self) -> &str {
|
||||
&self.auth_token
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod model;
|
||||
pub(crate) mod repository;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::IntoParams;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskActionType {
|
||||
Script,
|
||||
Http,
|
||||
Process,
|
||||
}
|
||||
|
||||
impl TaskActionType {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Script => "script",
|
||||
Self::Http => "http",
|
||||
Self::Process => "process",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum HttpMethod {
|
||||
#[serde(rename = "GET")]
|
||||
Get,
|
||||
#[serde(rename = "POST")]
|
||||
Post,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConcurrencyPolicy {
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RetryPolicy {
|
||||
#[serde(default)]
|
||||
pub max_attempts: u32,
|
||||
#[serde(default)]
|
||||
pub delay_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum TaskAction {
|
||||
Script {
|
||||
working_dir: String,
|
||||
interpreter: String,
|
||||
script: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
method: HttpMethod,
|
||||
#[serde(default)]
|
||||
headers: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
expected_status: Vec<u16>,
|
||||
},
|
||||
Process {
|
||||
executable: String,
|
||||
#[serde(default)]
|
||||
working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TaskAction {
|
||||
pub const fn action_type(&self) -> TaskActionType {
|
||||
match self {
|
||||
Self::Script { .. } => TaskActionType::Script,
|
||||
Self::Http { .. } => TaskActionType::Http,
|
||||
Self::Process { .. } => TaskActionType::Process,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
DEFAULT_TIMEZONE.to_owned()
|
||||
}
|
||||
|
||||
const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_timeout_secs() -> u64 {
|
||||
DEFAULT_TIMEOUT_SECS
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateTaskRequest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
#[serde(default)]
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateTaskRequest {
|
||||
pub name: String,
|
||||
pub cron: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
#[serde(default)]
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
#[serde(default)]
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct TaskResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub cron: String,
|
||||
pub timezone: String,
|
||||
pub enabled: bool,
|
||||
pub timeout_secs: u64,
|
||||
pub concurrency_policy: ConcurrencyPolicy,
|
||||
pub retry_policy: RetryPolicy,
|
||||
pub action: TaskAction,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TaskListResponse {
|
||||
pub items: Vec<TaskResponse>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct ListTasksQuery {
|
||||
pub q: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub action_type: Option<TaskActionType>,
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
tasks::model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, RetryPolicy, TaskAction, TaskActionType,
|
||||
TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
},
|
||||
};
|
||||
|
||||
const TASK_COLUMNS: &str = "
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
";
|
||||
|
||||
pub struct ListFilters {
|
||||
pub q: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub action_type: Option<TaskActionType>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct TaskRow {
|
||||
id: String,
|
||||
name: String,
|
||||
cron: String,
|
||||
timezone: String,
|
||||
enabled: bool,
|
||||
timeout_secs: i64,
|
||||
concurrency_policy: String,
|
||||
retry_max_attempts: i64,
|
||||
retry_delay_secs: i64,
|
||||
action_type: String,
|
||||
action_json: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<TaskRow> for TaskResponse {
|
||||
type Error = ApiError;
|
||||
|
||||
fn try_from(row: TaskRow) -> Result<Self, Self::Error> {
|
||||
if row.concurrency_policy != "skip" {
|
||||
return Err(ApiError::internal(format!(
|
||||
"unknown concurrency policy: {}",
|
||||
row.concurrency_policy
|
||||
)));
|
||||
}
|
||||
|
||||
let action: TaskAction = serde_json::from_str(&row.action_json)?;
|
||||
if row.action_type != action.action_type().as_str() {
|
||||
return Err(ApiError::internal(format!(
|
||||
"task {} action type does not match action payload",
|
||||
row.id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
cron: row.cron,
|
||||
timezone: row.timezone,
|
||||
enabled: row.enabled,
|
||||
timeout_secs: u64::try_from(row.timeout_secs).map_err(ApiError::internal)?,
|
||||
concurrency_policy: ConcurrencyPolicy::Skip,
|
||||
retry_policy: RetryPolicy {
|
||||
max_attempts: u32::try_from(row.retry_max_attempts).map_err(ApiError::internal)?,
|
||||
delay_secs: u64::try_from(row.retry_delay_secs).map_err(ApiError::internal)?,
|
||||
},
|
||||
action,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(pool: &SqlitePool, request: &CreateTaskRequest) -> ApiResult<TaskResponse> {
|
||||
let now = Utc::now();
|
||||
let action_json = serde_json::to_string(&request.action)?;
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO tasks (
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&request.id)
|
||||
.bind(&request.name)
|
||||
.bind(&request.cron)
|
||||
.bind(&request.timezone)
|
||||
.bind(request.enabled)
|
||||
.bind(request.timeout_secs as i64)
|
||||
.bind("skip")
|
||||
.bind(i64::from(request.retry_policy.max_attempts))
|
||||
.bind(request.retry_policy.delay_secs as i64)
|
||||
.bind(request.action.action_type().as_str())
|
||||
.bind(action_json)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
if is_unique_violation(&error) {
|
||||
return Err(ApiError::conflict(format!(
|
||||
"任务标识 {} 已存在",
|
||||
request.id
|
||||
)));
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
Ok(TaskResponse {
|
||||
id: request.id.clone(),
|
||||
name: request.name.clone(),
|
||||
cron: request.cron.clone(),
|
||||
timezone: request.timezone.clone(),
|
||||
enabled: request.enabled,
|
||||
timeout_secs: request.timeout_secs,
|
||||
concurrency_policy: request.concurrency_policy,
|
||||
retry_policy: request.retry_policy.clone(),
|
||||
action: request.action.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<Option<TaskResponse>> {
|
||||
let row = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(TaskResponse::try_from).transpose()
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
request: &UpdateTaskRequest,
|
||||
) -> ApiResult<TaskResponse> {
|
||||
let now = Utc::now();
|
||||
let action_json = serde_json::to_string(&request.action)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE tasks SET
|
||||
name = ?, cron = ?, timezone = ?, enabled = ?, timeout_secs = ?,
|
||||
concurrency_policy = ?, retry_max_attempts = ?, retry_delay_secs = ?,
|
||||
action_type = ?, action_json = ?, updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&request.name)
|
||||
.bind(&request.cron)
|
||||
.bind(&request.timezone)
|
||||
.bind(request.enabled)
|
||||
.bind(request.timeout_secs as i64)
|
||||
.bind("skip")
|
||||
.bind(i64::from(request.retry_policy.max_attempts))
|
||||
.bind(request.retry_policy.delay_secs as i64)
|
||||
.bind(request.action.action_type().as_str())
|
||||
.bind(action_json)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found(format!("任务 {id} 不存在")));
|
||||
}
|
||||
|
||||
get(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found(format!("任务 {id} 不存在")))
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<bool> {
|
||||
let result = sqlx::query("DELETE FROM tasks WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> ApiResult<bool> {
|
||||
let result = sqlx::query("UPDATE tasks SET enabled = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(enabled)
|
||||
.bind(Utc::now())
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, filters: &ListFilters) -> ApiResult<TaskListResponse> {
|
||||
let mut count_builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM tasks");
|
||||
apply_filters(&mut count_builder, filters);
|
||||
let total: i64 = count_builder.build_query_scalar().fetch_one(pool).await?;
|
||||
|
||||
let offset = i64::from(filters.page.saturating_sub(1)) * i64::from(filters.page_size);
|
||||
let mut list_builder = QueryBuilder::<Sqlite>::new(format!("SELECT {TASK_COLUMNS} FROM tasks"));
|
||||
apply_filters(&mut list_builder, filters);
|
||||
list_builder
|
||||
.push(" ORDER BY updated_at DESC, id ASC LIMIT ")
|
||||
.push_bind(i64::from(filters.page_size))
|
||||
.push(" OFFSET ")
|
||||
.push_bind(offset);
|
||||
|
||||
let rows = list_builder
|
||||
.build_query_as::<TaskRow>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(TaskResponse::try_from)
|
||||
.collect::<ApiResult<Vec<_>>>()?;
|
||||
|
||||
Ok(TaskListResponse {
|
||||
items,
|
||||
page: filters.page,
|
||||
page_size: filters.page_size,
|
||||
total: u64::try_from(total).map_err(ApiError::internal)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_enabled_script_tasks(pool: &SqlitePool) -> ApiResult<Vec<TaskResponse>> {
|
||||
let rows = sqlx::query_as::<_, TaskRow>(
|
||||
"SELECT
|
||||
id, name, cron, timezone, enabled, timeout_secs, concurrency_policy,
|
||||
retry_max_attempts, retry_delay_secs, action_type, action_json,
|
||||
created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE enabled = 1 AND action_type = 'script'
|
||||
ORDER BY updated_at DESC, id ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(TaskResponse::try_from)
|
||||
.collect::<ApiResult<Vec<_>>>()
|
||||
}
|
||||
|
||||
fn apply_filters(builder: &mut QueryBuilder<Sqlite>, filters: &ListFilters) {
|
||||
builder.push(" WHERE 1 = 1");
|
||||
|
||||
if let Some(search) = &filters.q {
|
||||
let pattern = format!(
|
||||
"%{}%",
|
||||
search
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
);
|
||||
builder
|
||||
.push(" AND (id LIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" ESCAPE '\\' OR name LIKE ")
|
||||
.push_bind(pattern)
|
||||
.push(" ESCAPE '\\')");
|
||||
}
|
||||
if let Some(enabled) = filters.enabled {
|
||||
builder.push(" AND enabled = ").push_bind(enabled);
|
||||
}
|
||||
if let Some(action_type) = filters.action_type {
|
||||
builder
|
||||
.push(" AND action_type = ")
|
||||
.push_bind(action_type.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unique_violation(error: &sqlx::Error) -> bool {
|
||||
matches!(error, sqlx::Error::Database(database_error) if database_error.is_unique_violation())
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiJson, ApiQuery, ApiResult},
|
||||
state::AppState,
|
||||
tasks::{
|
||||
model::{
|
||||
CreateTaskRequest, ListTasksQuery, TaskListResponse, TaskResponse, UpdateTaskRequest,
|
||||
},
|
||||
service,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/tasks", get(list_tasks).post(create_task))
|
||||
.route(
|
||||
"/api/v1/tasks/{id}",
|
||||
get(get_task).put(update_task).delete(delete_task),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/tasks/{id}/enable",
|
||||
axum::routing::post(enable_task),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/tasks/{id}/disable",
|
||||
axum::routing::post(disable_task),
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks",
|
||||
tag = "tasks",
|
||||
params(ListTasksQuery),
|
||||
responses(
|
||||
(status = 200, description = "Paginated task list", body = TaskListResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_tasks(
|
||||
State(state): State<AppState>,
|
||||
ApiQuery(query): ApiQuery<ListTasksQuery>,
|
||||
) -> ApiResult<Json<TaskListResponse>> {
|
||||
Ok(Json(service::list(&state.pool, query).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks",
|
||||
tag = "tasks",
|
||||
request_body = CreateTaskRequest,
|
||||
responses(
|
||||
(status = 201, description = "Task created", body = TaskResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Task ID already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_task(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreateTaskRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<TaskResponse>)> {
|
||||
let task = service::create(&state.pool, request).await?;
|
||||
state.scheduler.refresh(task.clone());
|
||||
Ok((StatusCode::CREATED, Json(task)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{id}",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task detail", body = TaskResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
Ok(Json(service::get(&state.pool, &id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/tasks/{id}",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
request_body = UpdateTaskRequest,
|
||||
responses(
|
||||
(status = 200, description = "Task updated", body = TaskResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn update_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
ApiJson(request): ApiJson<UpdateTaskRequest>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
let task = service::update(&state.pool, &id, request).await?;
|
||||
state.scheduler.refresh(task.clone());
|
||||
Ok(Json(task))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/tasks/{id}",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 204, description = "Task deleted"),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
state.scheduler.cancel(&id);
|
||||
state.executor.cancel_task(&id);
|
||||
service::delete(&state.pool, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks/{id}/enable",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task enabled", body = TaskResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn enable_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
let task = service::set_enabled(&state.pool, &id, true).await?;
|
||||
state.scheduler.refresh(task.clone());
|
||||
Ok(Json(task))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/tasks/{id}/disable",
|
||||
tag = "tasks",
|
||||
params(("id" = String, Path, description = "Stable task identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Task disabled", body = TaskResponse),
|
||||
(status = 404, description = "Task not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn disable_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<TaskResponse>> {
|
||||
let task = service::set_enabled(&state.pool, &id, false).await?;
|
||||
state.scheduler.cancel(&id);
|
||||
Ok(Json(task))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user