feat: implement full-stack TaskPilot scheduler
This commit is contained in:
Generated
+2927
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "task-pilot-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
chrono-tz = "0.10.4"
|
||||
croner = "4.0.1"
|
||||
futures-util = { version = "0.3.34", features = ["std"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.3", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.19", features = ["sync"] }
|
||||
tokio-util = "0.7.20"
|
||||
tower-http = { version = "0.7.1", features = ["cors", "fs", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
url = "2.5.8"
|
||||
utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.3.0"
|
||||
uuid = { version = "1.28.0", features = ["v4"] }
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.5"
|
||||
tempfile = "3.27.0"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cron TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
timeout_secs INTEGER NOT NULL CHECK (timeout_secs > 0),
|
||||
concurrency_policy TEXT NOT NULL DEFAULT 'skip'
|
||||
CHECK (concurrency_policy = 'skip'),
|
||||
retry_max_attempts INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_max_attempts >= 0),
|
||||
retry_delay_secs INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (retry_delay_secs >= 0),
|
||||
action_type TEXT NOT NULL
|
||||
CHECK (action_type IN ('script', 'http', 'process')),
|
||||
action_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at
|
||||
ON tasks(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
|
||||
ON tasks(enabled);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_action_type
|
||||
ON tasks(action_type);
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL
|
||||
CHECK (trigger_type IN ('schedule', 'manual', 'retry')),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN (
|
||||
'queued', 'running', 'succeeded', 'failed',
|
||||
'timed_out', 'cancelled', 'skipped'
|
||||
)),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
exit_code INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_created
|
||||
ON task_runs(task_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_status
|
||||
ON task_runs(status);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_endpoint_returns_service_metadata() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/health")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
|
||||
|
||||
assert_eq!(json["status"], "ok");
|
||||
assert_eq!(json["service"], "task-pilot-backend");
|
||||
assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_endpoint_describes_health_api() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/openapi.json")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("response should be JSON");
|
||||
|
||||
assert_eq!(json["info"]["title"], "TaskPilot API");
|
||||
assert!(json["paths"]["/api/v1/health"]["get"].is_object());
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
http::{HeaderMap, Method, Request, StatusCode, header},
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
use task_pilot_backend::database;
|
||||
use task_pilot_backend::state::AppState;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::sleep;
|
||||
use tokio::{net::TcpListener, task::JoinHandle};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use task_pilot_backend::runs::executor::ExecutionEvent;
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
state: AppState,
|
||||
_temp_dir: TempDir,
|
||||
}
|
||||
|
||||
async fn test_app() -> TestApp {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let state = AppState::start(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("app state should start");
|
||||
let router = task_pilot_backend::router(state.clone());
|
||||
TestApp {
|
||||
router,
|
||||
state,
|
||||
_temp_dir: temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_json(router: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
|
||||
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
|
||||
None
|
||||
} else {
|
||||
Some(fetch_api_token(router).await)
|
||||
};
|
||||
let body = body
|
||||
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
|
||||
.unwrap_or_else(Body::empty);
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = builder.body(body).expect("request should build");
|
||||
|
||||
router
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond")
|
||||
}
|
||||
|
||||
async fn fetch_api_token(router: &Router) -> String {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("session request should build");
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("session endpoint should respond");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response_json(response).await["token"]
|
||||
.as_str()
|
||||
.expect("session token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn response_json(response: Response) -> Value {
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
serde_json::from_slice(&body).expect("response should be JSON")
|
||||
}
|
||||
|
||||
async fn connect_file(path: &std::path::Path) -> SqlitePool {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("file database should open");
|
||||
database::migrate(&pool)
|
||||
.await
|
||||
.expect("migrations should run");
|
||||
pool
|
||||
}
|
||||
|
||||
fn script_task(
|
||||
id: &str,
|
||||
working_dir: &str,
|
||||
interpreter: &str,
|
||||
script: &str,
|
||||
args: Vec<&str>,
|
||||
cron: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"cron": cron,
|
||||
"timeout_secs": timeout_secs,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": working_dir,
|
||||
"interpreter": interpreter,
|
||||
"script": script,
|
||||
"args": args,
|
||||
"env": {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn http_task(url: &str) -> Value {
|
||||
json!({
|
||||
"id": "http_task",
|
||||
"name": "HTTP task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 5,
|
||||
"action": {
|
||||
"type": "http",
|
||||
"url": url,
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"X-Test": "yes"
|
||||
},
|
||||
"body": "{\"ping\":true}",
|
||||
"expected_status": [202]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_http_target() -> (String, JoinHandle<()>) {
|
||||
async fn check(headers: HeaderMap, body: String) -> StatusCode {
|
||||
if headers.get("x-test").and_then(|value| value.to_str().ok()) == Some("yes")
|
||||
&& body.contains("\"ping\":true")
|
||||
{
|
||||
StatusCode::ACCEPTED
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/check", post(check))
|
||||
.route("/health", get(|| async { Json(json!({"status":"ok"})) }));
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("HTTP test listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("listener address should exist");
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
(format!("http://{address}"), handle)
|
||||
}
|
||||
|
||||
async fn wait_for_run(router: &Router, run_id: i64) -> Value {
|
||||
for _ in 0..100 {
|
||||
let response =
|
||||
send_json(router, Method::GET, &format!("/api/v1/runs/{run_id}"), None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let run = response_json(response).await;
|
||||
if matches!(
|
||||
run["status"].as_str(),
|
||||
Some("succeeded" | "failed" | "timed_out" | "cancelled" | "skipped")
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
panic!("run {run_id} did not reach a terminal state");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_script_run_captures_exit_code_and_logs() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"echo_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["echo", "hello-from-task"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/echo_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
let run = response_json(run_response).await;
|
||||
let run_id = run["id"].as_i64().expect("run id should be an integer");
|
||||
assert_eq!(run["status"], "queued");
|
||||
|
||||
let completed = wait_for_run(&app.router, run_id).await;
|
||||
assert_eq!(completed["status"], "succeeded");
|
||||
assert_eq!(completed["exit_code"], 0);
|
||||
assert_eq!(completed["trigger"], "manual");
|
||||
|
||||
let log_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(log_response.status(), StatusCode::OK);
|
||||
let log = response_json(log_response).await;
|
||||
assert_eq!(log["complete"], true);
|
||||
assert!(log["content"].as_str().unwrap().contains("hello-from-task"));
|
||||
|
||||
let filtered_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/echo_task/runs?status=succeeded&trigger=manual",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let filtered = response_json(filtered_response).await;
|
||||
assert_eq!(filtered["total"], 1);
|
||||
|
||||
let download_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs/download"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(download_response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
download_response
|
||||
.headers()
|
||||
.get(header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.contains("attachment")
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_and_process_executors_run_through_the_api() {
|
||||
let app = test_app().await;
|
||||
let (http_base, http_handle) = spawn_http_target().await;
|
||||
let create_http = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(http_task(&format!("{http_base}/check"))),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_http.status(), StatusCode::CREATED);
|
||||
|
||||
let http_run = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/http_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(http_run.status(), StatusCode::ACCEPTED);
|
||||
let http_run_id = response_json(http_run).await["id"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, http_run_id).await["status"],
|
||||
"succeeded"
|
||||
);
|
||||
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let process_task = json!({
|
||||
"id": "process_task",
|
||||
"name": "Process task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 5,
|
||||
"action": {
|
||||
"type": "process",
|
||||
"executable": "cmd.exe",
|
||||
"working_dir": working_dir,
|
||||
"args": ["/C", "echo", "process-output"],
|
||||
"env": {}
|
||||
}
|
||||
});
|
||||
let create_process = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(process_task),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_process.status(), StatusCode::CREATED);
|
||||
let process_run = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/process_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let process_run_id = response_json(process_run).await["id"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, process_run_id).await["status"],
|
||||
"succeeded"
|
||||
);
|
||||
let process_log = response_json(
|
||||
send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{process_run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
process_log["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("process-output")
|
||||
);
|
||||
|
||||
http_handle.abort();
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn running_task_can_be_cancelled_and_sensitive_output_is_redacted() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = json!({
|
||||
"id": "cancel_task",
|
||||
"name": "Cancel task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 30,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": working_dir,
|
||||
"interpreter": "cmd.exe",
|
||||
"script": "/C",
|
||||
"args": ["ping", "-t", "127.0.0.1"],
|
||||
"env": {
|
||||
"API_TOKEN": "super-secret-token"
|
||||
}
|
||||
}
|
||||
});
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let mut events = app.state.executor.subscribe();
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/cancel_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let run_id = response_json(run_response).await["id"].as_i64().unwrap();
|
||||
|
||||
let cancel_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
&format!("/api/v1/runs/{run_id}/cancel"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cancel_response.status(), StatusCode::ACCEPTED);
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, run_id).await["status"],
|
||||
"cancelled"
|
||||
);
|
||||
|
||||
let mut saw_run_event = false;
|
||||
let mut saw_log_event = false;
|
||||
for _ in 0..20 {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), events.recv())
|
||||
.await
|
||||
.expect("SSE event should arrive")
|
||||
.expect("SSE channel should stay open");
|
||||
match event {
|
||||
ExecutionEvent::Run { run } if run.id == run_id => saw_run_event = true,
|
||||
ExecutionEvent::Log {
|
||||
run_id: event_run_id,
|
||||
..
|
||||
} if event_run_id == run_id => saw_log_event = true,
|
||||
_ => {}
|
||||
}
|
||||
if saw_run_event && saw_log_event {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_run_event);
|
||||
assert!(saw_log_event);
|
||||
|
||||
let terminal_cancel = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
&format!("/api/v1/runs/{run_id}/cancel"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(terminal_cancel.status(), StatusCode::CONFLICT);
|
||||
|
||||
let output_task = json!({
|
||||
"id": "redaction_task",
|
||||
"name": "Redaction task",
|
||||
"cron": "0 0 2 * * *",
|
||||
"timeout_secs": 5,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": app._temp_dir.path().to_string_lossy(),
|
||||
"interpreter": "cmd.exe",
|
||||
"script": "/C",
|
||||
"args": ["echo", "%API_TOKEN%"],
|
||||
"env": {
|
||||
"API_TOKEN": "super-secret-token"
|
||||
}
|
||||
}
|
||||
});
|
||||
let create_output = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(output_task),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_output.status(), StatusCode::CREATED);
|
||||
let output_run = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/redaction_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let output_run_id = response_json(output_run).await["id"].as_i64().unwrap();
|
||||
wait_for_run(&app.router, output_run_id).await;
|
||||
let output_log = response_json(
|
||||
send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{output_run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
let content = output_log["content"].as_str().unwrap();
|
||||
assert!(content.contains("[REDACTED]"));
|
||||
assert!(!content.contains("super-secret-token"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlapping_trigger_is_skipped_and_timeout_kills_process_tree() {
|
||||
let app = test_app().await;
|
||||
let marker_path = app._temp_dir.path().join("child-survived.txt");
|
||||
let marker = marker_path.to_string_lossy().replace('\'', "''");
|
||||
let script_path = app._temp_dir.path().join("timeout-test.ps1");
|
||||
let script = format!(
|
||||
"$marker = '{marker}'\n\
|
||||
$child = \"Start-Sleep -Seconds 3; Set-Content -LiteralPath '{marker}' -Value 'alive'\"\n\
|
||||
Start-Process -FilePath 'powershell.exe' -ArgumentList '-NoProfile', '-Command', $child -WindowStyle Hidden\n\
|
||||
Start-Sleep -Seconds 30\n"
|
||||
);
|
||||
std::fs::write(&script_path, script).expect("test script should be written");
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"timeout_task",
|
||||
&working_dir,
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
vec!["-File", &script_path.to_string_lossy()],
|
||||
"0 0 2 * * *",
|
||||
1,
|
||||
);
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let first_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/timeout_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first_response.status(), StatusCode::ACCEPTED);
|
||||
let first_run = response_json(first_response).await;
|
||||
let first_run_id = first_run["id"].as_i64().unwrap();
|
||||
|
||||
let second_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/timeout_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second_response.status(), StatusCode::ACCEPTED);
|
||||
let second_run = response_json(second_response).await;
|
||||
assert_eq!(second_run["status"], "skipped");
|
||||
|
||||
let completed = wait_for_run(&app.router, first_run_id).await;
|
||||
assert_eq!(completed["status"], "timed_out");
|
||||
assert!(
|
||||
completed["error_message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("已终止进程树")
|
||||
);
|
||||
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
assert!(
|
||||
!marker_path.exists(),
|
||||
"child process survived after job termination"
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_runs_tasks_and_tracks_enable_state() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"scheduled_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"*/1 * * * * *",
|
||||
5,
|
||||
);
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
|
||||
|
||||
let mut scheduled_run = None;
|
||||
for _ in 0..50 {
|
||||
let response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/scheduled_task/runs",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let runs = response_json(response).await;
|
||||
if let Some(item) = runs["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.filter(|item| item["trigger"] == "schedule")
|
||||
{
|
||||
scheduled_run = Some(item.clone());
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let scheduled_run = scheduled_run.expect("scheduler should trigger a run");
|
||||
let run_id = scheduled_run["id"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
wait_for_run(&app.router, run_id).await["status"],
|
||||
"succeeded"
|
||||
);
|
||||
|
||||
let disable_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/scheduled_task/disable",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(disable_response.status(), StatusCode::OK);
|
||||
assert!(!app.state.scheduler.is_scheduled("scheduled_task"));
|
||||
|
||||
let enable_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/scheduled_task/enable",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(enable_response.status(), StatusCode::OK);
|
||||
assert!(app.state.scheduler.is_scheduled("scheduled_task"));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_restores_enabled_tasks_after_restart() {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let database_path = temp_dir.path().join("restart.db");
|
||||
let first_pool = connect_file(&database_path).await;
|
||||
let first_state = AppState::start(first_pool.clone(), temp_dir.path().join("logs-first"))
|
||||
.await
|
||||
.expect("first app state should start");
|
||||
let first_router = task_pilot_backend::router(first_state.clone());
|
||||
let working_dir = temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(
|
||||
"restored_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"/C",
|
||||
vec!["exit", "0"],
|
||||
"0 0 2 * * *",
|
||||
5,
|
||||
);
|
||||
let create_response = send_json(&first_router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
first_state.shutdown();
|
||||
drop(first_router);
|
||||
drop(first_state);
|
||||
first_pool.close().await;
|
||||
|
||||
let second_pool = connect_file(&database_path).await;
|
||||
let second_state = AppState::start(second_pool.clone(), temp_dir.path().join("logs-second"))
|
||||
.await
|
||||
.expect("second app state should start");
|
||||
assert!(second_state.scheduler.is_scheduled("restored_task"));
|
||||
second_state.shutdown();
|
||||
second_pool.close().await;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use task_pilot_backend::state::AppState;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_spa_and_keeps_api_404_as_json() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let static_dir = temp.path().join("web");
|
||||
std::fs::create_dir_all(&static_dir).unwrap();
|
||||
std::fs::write(
|
||||
static_dir.join("index.html"),
|
||||
"<!doctype html><title>TaskPilot SPA</title>",
|
||||
)
|
||||
.unwrap();
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = AppState::start(pool, temp.path().join("logs"))
|
||||
.await
|
||||
.unwrap();
|
||||
let app = task_pilot_backend::router_with_static(state.clone(), static_dir);
|
||||
|
||||
let page = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/tasks/example")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.status(), StatusCode::OK);
|
||||
let page_body = page.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(String::from_utf8_lossy(&page_body).contains("TaskPilot SPA"));
|
||||
|
||||
let api_not_found = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/does-not-exist")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(api_not_found.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
api_not_found.headers().get(header::CONTENT_TYPE),
|
||||
Some(&header::HeaderValue::from_static("application/json"))
|
||||
);
|
||||
state.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{
|
||||
SqlitePool,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
use task_pilot_backend::database;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn connect_file(path: &std::path::Path) -> SqlitePool {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("file database should open");
|
||||
database::migrate(&pool)
|
||||
.await
|
||||
.expect("migrations should run");
|
||||
pool
|
||||
}
|
||||
|
||||
async fn send_json(app: &Router, method: Method, uri: &str, body: Option<Value>) -> Response {
|
||||
let token = if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
|
||||
None
|
||||
} else {
|
||||
Some(fetch_api_token(app).await)
|
||||
};
|
||||
let body = body
|
||||
.map(|value| Body::from(serde_json::to_vec(&value).expect("JSON should serialize")))
|
||||
.unwrap_or_else(Body::empty);
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = builder.body(body).expect("request should build");
|
||||
|
||||
app.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond")
|
||||
}
|
||||
|
||||
async fn fetch_api_token(app: &Router) -> String {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("session request should build");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("session endpoint should respond");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response_json(response).await["token"]
|
||||
.as_str()
|
||||
.expect("session token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn response_json(response: Response) -> Value {
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should be readable")
|
||||
.to_bytes();
|
||||
serde_json::from_slice(&body).expect("response should be JSON")
|
||||
}
|
||||
|
||||
fn script_task(id: &str, name: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"cron": "0 0 2 * * *",
|
||||
"enabled": true,
|
||||
"timeout_secs": 300,
|
||||
"action": {
|
||||
"type": "script",
|
||||
"working_dir": "D:\\Projects\\sync",
|
||||
"interpreter": "python.exe",
|
||||
"script": "main.py",
|
||||
"args": ["--mode", "full"],
|
||||
"env": {
|
||||
"PYTHONUNBUFFERED": "1"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_crud_flow_works_end_to_end() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let create_response = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("sync_data", "Data sync")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
let created = response_json(create_response).await;
|
||||
assert_eq!(created["id"], "sync_data");
|
||||
assert_eq!(created["timezone"], "Asia/Shanghai");
|
||||
assert_eq!(created["action"]["type"], "script");
|
||||
|
||||
let get_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
assert_eq!(response_json(get_response).await["name"], "Data sync");
|
||||
|
||||
let list_response = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
"/api/v1/tasks?q=sync&enabled=true&action_type=script&page=1&page_size=10",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(list_response.status(), StatusCode::OK);
|
||||
let listed = response_json(list_response).await;
|
||||
assert_eq!(listed["total"], 1);
|
||||
assert_eq!(listed["items"][0]["id"], "sync_data");
|
||||
|
||||
let update_response = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/tasks/sync_data",
|
||||
Some(json!({
|
||||
"name": "Updated data sync",
|
||||
"cron": "*/30 * * * * *",
|
||||
"enabled": false,
|
||||
"timeout_secs": 120,
|
||||
"action": {
|
||||
"type": "http",
|
||||
"url": "https://example.com/health",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"expected_status": [200]
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(update_response.status(), StatusCode::OK);
|
||||
let updated = response_json(update_response).await;
|
||||
assert_eq!(updated["name"], "Updated data sync");
|
||||
assert_eq!(updated["enabled"], false);
|
||||
assert_eq!(updated["action"]["type"], "http");
|
||||
|
||||
let delete_response = send_json(&app, Method::DELETE, "/api/v1/tasks/sync_data", None).await;
|
||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let missing_response = send_json(&app, Method::GET, "/api/v1/tasks/sync_data", None).await;
|
||||
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
|
||||
let missing = response_json(missing_response).await;
|
||||
assert_eq!(missing["code"], "TASK_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_task_id_returns_conflict() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let first = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("duplicate", "First")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first.status(), StatusCode::CREATED);
|
||||
|
||||
let second = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("duplicate", "Second")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(second.status(), StatusCode::CONFLICT);
|
||||
let body = response_json(second).await;
|
||||
assert_eq!(body["code"], "TASK_ID_CONFLICT");
|
||||
assert_eq!(body["field"], "id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_cron_returns_structured_validation_error() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let mut request = script_task("invalid_cron", "Invalid cron");
|
||||
request["cron"] = Value::String("*/0 * * * * *".to_owned());
|
||||
|
||||
let response = send_json(&app, Method::POST, "/api/v1/tasks", Some(request)).await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["code"], "VALIDATION_ERROR");
|
||||
assert_eq!(body["field"], "cron");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_and_query_return_structured_errors() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = fetch_api_token(&app).await;
|
||||
let malformed_request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/tasks")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-taskpilot-token", token)
|
||||
.body(Body::from("{not-json"))
|
||||
.expect("request should build");
|
||||
let malformed_response = app
|
||||
.clone()
|
||||
.oneshot(malformed_request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
assert_eq!(malformed_response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response_json(malformed_response).await["code"],
|
||||
"INVALID_JSON"
|
||||
);
|
||||
|
||||
let invalid_query = send_json(&app, Method::GET, "/api/v1/tasks?enabled=maybe", None).await;
|
||||
assert_eq!(invalid_query.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response_json(invalid_query).await["code"], "INVALID_QUERY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_cross_origin_and_missing_management_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let cross_origin = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/v1/tasks")
|
||||
.header(header::ORIGIN, "https://evil.example")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(cross_origin)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let missing_token = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/tasks")
|
||||
.header(header::ORIGIN, "http://127.0.0.1:5173")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&script_task("blocked_task", "Blocked"))
|
||||
.expect("JSON should serialize"),
|
||||
))
|
||||
.expect("request should build");
|
||||
let response = app
|
||||
.oneshot(missing_token)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(response_json(response).await["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_supports_pagination_and_escaped_search() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
for (id, name) in [
|
||||
("sync_one", "Sync one"),
|
||||
("sync_two", "Sync two"),
|
||||
("other", "100% complete"),
|
||||
] {
|
||||
let response = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task(id, name)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
let first_page = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
"/api/v1/tasks?q=sync&page=1&page_size=1",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let first_page = response_json(first_page).await;
|
||||
assert_eq!(first_page["page"], 1);
|
||||
assert_eq!(first_page["page_size"], 1);
|
||||
assert_eq!(first_page["total"], 2);
|
||||
assert_eq!(first_page["items"].as_array().map(Vec::len), Some(1));
|
||||
|
||||
let escaped = send_json(&app, Method::GET, "/api/v1/tasks?q=%25", None).await;
|
||||
let escaped = response_json(escaped).await;
|
||||
assert_eq!(escaped["total"], 1);
|
||||
assert_eq!(escaped["items"][0]["id"], "other");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tasks_survive_database_reconnect() {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let database_path = temp_dir.path().join("task_pilot_test.db");
|
||||
|
||||
let first_pool = connect_file(&database_path).await;
|
||||
let first_app = task_pilot_backend::app(first_pool.clone(), temp_dir.path().join("logs-first"))
|
||||
.await
|
||||
.expect("first app should start");
|
||||
let create_response = send_json(
|
||||
&first_app,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Some(script_task("persistent_task", "Persistent task")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
first_pool.close().await;
|
||||
drop(first_app);
|
||||
|
||||
let second_pool = connect_file(&database_path).await;
|
||||
let second_app =
|
||||
task_pilot_backend::app(second_pool.clone(), temp_dir.path().join("logs-second"))
|
||||
.await
|
||||
.expect("second app should start");
|
||||
let get_response = send_json(
|
||||
&second_app,
|
||||
Method::GET,
|
||||
"/api/v1/tasks/persistent_task",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
assert_eq!(response_json(get_response).await["name"], "Persistent task");
|
||||
second_pool.close().await;
|
||||
}
|
||||
Reference in New Issue
Block a user