Files
task_pilot/backend/src/runs/executor.rs
T

983 lines
31 KiB
Rust

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,
python_env::prepare_script_environment,
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,
venv,
interpreter,
script,
args,
env,
} => {
let prepared = match prepare_script_environment(
working_dir,
interpreter,
venv.as_deref(),
env,
) {
Ok(prepared) => prepared,
Err(error) => {
return failed_outcome(format!("准备 Python 运行环境失败: {error}"));
}
};
let mut command = Command::new(&prepared.interpreter);
prepared.apply(&mut command);
command.arg(script).args(args).current_dir(working_dir);
let startup_note = prepared
.virtual_env
.as_ref()
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
self.run_command(task, run_id, cancellation, command, startup_note)
.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, None)
.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,
startup_note: Option<String>,
) -> 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}")),
};
if let Some(note) = startup_note {
sink.system(&note).await;
}
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);
}
}