use async_trait::async_trait;
use std::path::PathBuf;
use super::cron::CronExpression;
use super::HostRunMetadata;
pub fn render_command(program: &std::path::Path, args: &[String]) -> String {
let mut parts = vec![shell_quote(&program.display().to_string())];
for arg in args {
parts.push(shell_quote(arg));
}
parts.join(" ")
}
pub fn render_cron_command(program: &std::path::Path, args: &[String]) -> String {
render_command(program, args).replace('%', "\\%")
}
fn shell_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | '.' | ':' | '='))
{
return s.to_string();
}
format!("'{}'", s.replace('\'', "'\"'\"'"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchedulerInstallContext {
pub runner_executable: PathBuf,
pub config_store_path: PathBuf,
}
impl SchedulerInstallContext {
pub fn generate_invocation(&self, automation_task_id: &str) -> (PathBuf, Vec<String>) {
(
self.runner_executable.clone(),
vec![
"run".to_string(),
automation_task_id.to_string(),
"--config".to_string(),
self.config_store_path.display().to_string(),
],
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostInstallRequest {
pub schedule_id: String,
pub automation_task_id: String,
pub cron: CronExpression,
pub enabled: bool,
pub program: PathBuf,
pub args: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObservedHostEntry {
pub schedule_id: String,
pub enabled: bool,
pub corrupt: bool,
pub raw_schedule: Option<String>,
pub observed_command: Option<String>,
pub metadata: Option<HostRunMetadata>,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum HostSchedulerError {
#[error("unsupported schedule for {platform}: {reason}")]
UnsupportedSchedule {
platform: &'static str,
reason: String,
},
#[error("platform unavailable: {0}")]
PlatformUnavailable(String),
#[error("io error: {0}")]
Io(String),
#[error("compilation failed: {0}")]
CompilationFailed(String),
}
#[async_trait]
pub trait HostScheduler: Send + Sync {
fn platform(&self) -> &'static str;
async fn install(&self, request: &HostInstallRequest) -> Result<(), HostSchedulerError>;
async fn remove(&self, schedule_id: &str) -> Result<(), HostSchedulerError>;
async fn list_owned(&self) -> Result<Vec<ObservedHostEntry>, HostSchedulerError>;
async fn inspect(
&self,
schedule_id: &str,
) -> Result<Option<ObservedHostEntry>, HostSchedulerError>;
}
pub fn create_host_scheduler(
_context: SchedulerInstallContext,
) -> Result<Box<dyn HostScheduler>, HostSchedulerError> {
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
use super::platform::cron_adapter::CronHostScheduler;
Ok(Box::new(CronHostScheduler::new(Box::new(ProductionCommandRunner))))
} else if #[cfg(target_os = "macos")] {
use super::platform::launchd::LaunchdHostScheduler;
use std::path::PathBuf;
let home = std::env::var("HOME")
.map_err(|_| HostSchedulerError::PlatformUnavailable(
"HOME environment variable is not set; cannot locate LaunchAgents directory".to_string(),
))?;
if home.is_empty() {
return Err(HostSchedulerError::PlatformUnavailable(
"HOME environment variable is empty; cannot locate LaunchAgents directory".to_string(),
));
}
let dir = PathBuf::from(&home).join("Library/LaunchAgents");
if !dir.is_absolute() {
return Err(HostSchedulerError::PlatformUnavailable(
format!("resolved LaunchAgents path is not absolute: {}", dir.display()),
));
}
Ok(Box::new(LaunchdHostScheduler::new(Box::new(ProductionCommandRunner), dir)))
} else if #[cfg(target_os = "windows")] {
use super::platform::task_scheduler::TaskSchedulerHostScheduler;
Ok(Box::new(TaskSchedulerHostScheduler::new(Box::new(ProductionCommandRunner))))
} else {
Err(HostSchedulerError::PlatformUnavailable(
"unsupported platform".to_string(),
))
}
}
}
pub struct ProductionCommandRunner;
const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[async_trait]
impl CommandRunner for ProductionCommandRunner {
async fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutput, std::io::Error> {
let fut = async {
let output = tokio::process::Command::new(program)
.args(args)
.output()
.await?;
Ok(CommandOutput {
exit_code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
};
match tokio::time::timeout(COMMAND_TIMEOUT, fut).await {
Ok(result) => result,
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"command '{}' timed out after {}s",
program,
COMMAND_TIMEOUT.as_secs()
),
)),
}
}
async fn run_with_stdin(
&self,
program: &str,
args: &[&str],
stdin: &str,
) -> Result<CommandOutput, std::io::Error> {
use tokio::io::AsyncWriteExt;
let stdin_bytes = stdin.as_bytes().to_vec();
let fut = async {
let mut child = tokio::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(ref mut stdin_handle) = child.stdin {
stdin_handle.write_all(&stdin_bytes).await?;
}
let output = child.wait_with_output().await?;
Ok(CommandOutput {
exit_code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
};
match tokio::time::timeout(COMMAND_TIMEOUT, fut).await {
Ok(result) => result,
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"command '{}' timed out after {}s",
program,
COMMAND_TIMEOUT.as_secs()
),
)),
}
}
}
#[async_trait]
pub trait CommandRunner: Send + Sync {
async fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutput, std::io::Error>;
async fn run_with_stdin(
&self,
program: &str,
args: &[&str],
stdin: &str,
) -> Result<CommandOutput, std::io::Error>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[async_trait]
pub trait SchedulerFilesystem: Send + Sync {
async fn read_to_string(&self, path: &std::path::Path) -> Result<String, std::io::Error>;
async fn write(&self, path: &std::path::Path, content: &str) -> Result<(), std::io::Error>;
async fn exists(&self, path: &std::path::Path) -> bool;
async fn remove_file(&self, path: &std::path::Path) -> Result<(), std::io::Error>;
}
#[derive(Debug, Default)]
pub struct FakeHostScheduler {
entries: parking_lot::RwLock<std::collections::HashMap<String, ObservedHostEntry>>,
pub force_error: parking_lot::Mutex<Option<HostSchedulerError>>,
install_count: std::sync::atomic::AtomicU32,
}
impl FakeHostScheduler {
pub fn new() -> Self {
Self::default()
}
pub fn install_count(&self) -> u32 {
self.install_count
.load(std::sync::atomic::Ordering::Relaxed)
}
fn check_error(&self) -> Result<(), HostSchedulerError> {
if let Some(ref e) = *self.force_error.lock() {
return Err(e.clone());
}
Ok(())
}
}
#[async_trait]
impl HostScheduler for FakeHostScheduler {
fn platform(&self) -> &'static str {
"fake"
}
async fn install(&self, request: &HostInstallRequest) -> Result<(), HostSchedulerError> {
self.check_error()?;
self.install_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let command = render_command(&request.program, &request.args);
let mut entries = self.entries.write();
entries.insert(
request.schedule_id.clone(),
ObservedHostEntry {
schedule_id: request.schedule_id.clone(),
enabled: request.enabled,
corrupt: false,
raw_schedule: Some(request.cron.as_str().to_string()),
observed_command: Some(command),
metadata: None,
},
);
Ok(())
}
async fn remove(&self, schedule_id: &str) -> Result<(), HostSchedulerError> {
self.check_error()?;
let mut entries = self.entries.write();
entries.remove(schedule_id);
Ok(())
}
async fn list_owned(&self) -> Result<Vec<ObservedHostEntry>, HostSchedulerError> {
self.check_error()?;
let entries = self.entries.read();
let mut list: Vec<_> = entries.values().cloned().collect();
list.sort_by(|a, b| a.schedule_id.cmp(&b.schedule_id));
Ok(list)
}
async fn inspect(
&self,
schedule_id: &str,
) -> Result<Option<ObservedHostEntry>, HostSchedulerError> {
self.check_error()?;
let entries = self.entries.read();
Ok(entries.get(schedule_id).cloned())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_context() -> SchedulerInstallContext {
SchedulerInstallContext {
runner_executable: PathBuf::from("/usr/local/bin/agent-iron"),
config_store_path: PathBuf::from("/home/user/.config/agentiron/config.db"),
}
}
fn make_request(id: &str, cron: &str, enabled: bool) -> HostInstallRequest {
let ctx = test_context();
let (program, args) = ctx.generate_invocation("task-1");
HostInstallRequest {
schedule_id: id.to_string(),
automation_task_id: "task-1".to_string(),
cron: CronExpression::parse(cron).unwrap(),
enabled,
program,
args,
}
}
#[test]
fn generate_invocation_uses_absolute_paths() {
let ctx = test_context();
let (program, args) = ctx.generate_invocation("daily-report");
assert!(program.starts_with("/usr/local/bin/agent-iron"));
assert!(args.contains(&"run".to_string()));
assert!(args.contains(&"daily-report".to_string()));
assert!(args.contains(&"--config".to_string()));
}
#[test]
fn generate_invocation_no_path_reliance() {
let ctx = test_context();
let (program, _) = ctx.generate_invocation("task-1");
assert!(!program.starts_with("agent-iron"));
assert!(program.is_absolute());
}
#[test]
fn factory_returns_scheduler_on_supported_platform() {
let ctx = test_context();
let result = create_host_scheduler(ctx);
cfg_if::cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(HostSchedulerError::PlatformUnavailable(_))));
}
}
}
#[tokio::test]
async fn fake_install_and_inspect() {
let scheduler = FakeHostScheduler::new();
let request = make_request("s1", "0 9 * * *", true);
scheduler.install(&request).await.unwrap();
let entry = scheduler.inspect("s1").await.unwrap().unwrap();
assert_eq!(entry.schedule_id, "s1");
assert!(entry.enabled);
assert_eq!(entry.raw_schedule.as_deref(), Some("0 9 * * *"));
}
#[tokio::test]
async fn fake_remove() {
let scheduler = FakeHostScheduler::new();
scheduler
.install(&make_request("s1", "0 9 * * *", true))
.await
.unwrap();
scheduler.remove("s1").await.unwrap();
assert!(scheduler.inspect("s1").await.unwrap().is_none());
}
#[tokio::test]
async fn fake_list_owned_sorted() {
let scheduler = FakeHostScheduler::new();
for id in &["charlie", "alpha", "bravo"] {
scheduler
.install(&make_request(id, "0 9 * * *", true))
.await
.unwrap();
}
let list = scheduler.list_owned().await.unwrap();
let ids: Vec<&str> = list.iter().map(|e| e.schedule_id.as_str()).collect();
assert_eq!(ids, vec!["alpha", "bravo", "charlie"]);
}
#[tokio::test]
async fn fake_replace_on_reinstall() {
let scheduler = FakeHostScheduler::new();
scheduler
.install(&make_request("s1", "0 9 * * *", true))
.await
.unwrap();
scheduler
.install(&make_request("s1", "0 9 * * *", false))
.await
.unwrap();
let entry = scheduler.inspect("s1").await.unwrap().unwrap();
assert!(!entry.enabled);
}
#[tokio::test]
async fn fake_install_request_has_no_arbitrary_command() {
let _scheduler = FakeHostScheduler::new();
let request = make_request("s1", "0 9 * * *", true);
assert!(request.program.is_absolute());
assert!(request.args.contains(&"run".to_string()));
assert!(request.args.contains(&"task-1".to_string()));
}
#[tokio::test]
async fn fake_force_error() {
let scheduler = FakeHostScheduler::new();
*scheduler.force_error.lock() =
Some(HostSchedulerError::PlatformUnavailable("test".to_string()));
let result = scheduler.list_owned().await;
assert!(matches!(
result,
Err(HostSchedulerError::PlatformUnavailable(_))
));
}
#[test]
fn shell_quote_safe_chars() {
assert_eq!(shell_quote("hello"), "hello");
assert_eq!(shell_quote("/usr/bin/agent-iron"), "/usr/bin/agent-iron");
assert_eq!(shell_quote("a-b_c.d"), "a-b_c.d");
}
#[test]
fn shell_quote_unsafe_chars() {
assert_eq!(shell_quote(""), "''");
assert_eq!(shell_quote("hello world"), "'hello world'");
assert_eq!(
shell_quote("/path with spaces/run"),
"'/path with spaces/run'"
);
}
}