Skip to content

Commit 70da343

Browse files
aster-voidclaude
andcommitted
treewide: add per-job timezone, enabled flag, and .env loading
- Per-job timezone: jobs can override runner timezone with their own timezone setting (e.g., timezone: America/New_York) - Enabled flag: jobs can be disabled with enabled: false - .env loading: automatically load .env file from job working directory - Security: add path traversal protection for working_dir - Fix unwrap in retry backoff calculation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 196201c commit 70da343

File tree

5 files changed

+297
-13
lines changed

5 files changed

+297
-13
lines changed

src/config.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ pub struct JobConfig {
6969
pub retry: Option<RetryConfigRaw>,
7070
pub working_dir: Option<String>,
7171
pub jitter: Option<String>,
72+
pub enabled: Option<bool>,
73+
pub timezone: Option<String>,
7274
}
7375

7476
#[derive(Debug, Deserialize)]
@@ -104,6 +106,8 @@ pub struct Job {
104106
pub retry: Option<RetryConfig>,
105107
pub working_dir: Option<String>,
106108
pub jitter: Option<Duration>,
109+
pub enabled: bool,
110+
pub timezone: Option<TimezoneConfig>,
107111
}
108112

109113
#[derive(Debug, Clone)]
@@ -168,6 +172,19 @@ pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
168172
.map_err(|e| anyhow!("Invalid jitter '{}' in job '{}': {}", j, id, e)))
169173
.transpose()?;
170174

175+
let job_timezone = job
176+
.timezone
177+
.map(|tz| {
178+
if tz == "inherit" {
179+
Ok(TimezoneConfig::Inherit)
180+
} else {
181+
tz.parse::<Tz>()
182+
.map(TimezoneConfig::Named)
183+
.map_err(|e| anyhow!("Invalid timezone '{}' in job '{}': {}", tz, id, e))
184+
}
185+
})
186+
.transpose()?;
187+
171188
Ok(Job {
172189
id,
173190
name,
@@ -178,6 +195,8 @@ pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
178195
retry,
179196
working_dir: job.working_dir,
180197
jitter,
198+
enabled: job.enabled.unwrap_or(true),
199+
timezone: job_timezone,
181200
})
182201
})
183202
.collect::<Result<Vec<_>>>()?;
@@ -541,6 +560,100 @@ jobs:
541560
run: echo test
542561
retry:
543562
max: 0
563+
"#;
564+
assert!(parse_config(yaml).is_err());
565+
}
566+
567+
#[test]
568+
fn parse_enabled_false() {
569+
let yaml = r#"
570+
jobs:
571+
test_disabled:
572+
schedule:
573+
cron: "* * * * *"
574+
run: echo test
575+
enabled: false
576+
"#;
577+
let (_, jobs) = parse_config(yaml).unwrap();
578+
assert_eq!(jobs[0].enabled, false);
579+
}
580+
581+
#[test]
582+
fn parse_enabled_true() {
583+
let yaml = r#"
584+
jobs:
585+
test_enabled:
586+
schedule:
587+
cron: "* * * * *"
588+
run: echo test
589+
enabled: true
590+
"#;
591+
let (_, jobs) = parse_config(yaml).unwrap();
592+
assert_eq!(jobs[0].enabled, true);
593+
}
594+
595+
#[test]
596+
fn parse_enabled_default() {
597+
let yaml = r#"
598+
jobs:
599+
test_default:
600+
schedule:
601+
cron: "* * * * *"
602+
run: echo test
603+
"#;
604+
let (_, jobs) = parse_config(yaml).unwrap();
605+
assert_eq!(jobs[0].enabled, true);
606+
}
607+
608+
#[test]
609+
fn parse_job_with_timezone() {
610+
let yaml = r#"
611+
runner:
612+
timezone: Asia/Tokyo
613+
jobs:
614+
job1:
615+
schedule:
616+
cron: "* * * * *"
617+
run: echo test
618+
timezone: America/New_York
619+
job2:
620+
schedule:
621+
cron: "* * * * *"
622+
run: echo test
623+
"#;
624+
let (runner, jobs) = parse_config(yaml).unwrap();
625+
assert_eq!(runner.timezone, TimezoneConfig::Named(chrono_tz::Asia::Tokyo));
626+
let find = |id: &str| jobs.iter().find(|j| j.id == id).unwrap();
627+
assert_eq!(
628+
find("job1").timezone,
629+
Some(TimezoneConfig::Named(chrono_tz::America::New_York))
630+
);
631+
assert!(find("job2").timezone.is_none());
632+
}
633+
634+
#[test]
635+
fn parse_job_timezone_inherit() {
636+
let yaml = r#"
637+
jobs:
638+
test:
639+
schedule:
640+
cron: "* * * * *"
641+
run: echo test
642+
timezone: inherit
643+
"#;
644+
let (_, jobs) = parse_config(yaml).unwrap();
645+
assert_eq!(jobs[0].timezone, Some(TimezoneConfig::Inherit));
646+
}
647+
648+
#[test]
649+
fn parse_invalid_job_timezone() {
650+
let yaml = r#"
651+
jobs:
652+
test:
653+
schedule:
654+
cron: "* * * * *"
655+
run: echo test
656+
timezone: Invalid/Zone
544657
"#;
545658
assert!(parse_config(yaml).is_err());
546659
}

src/env.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
use anyhow::Result;
2+
use std::collections::HashMap;
3+
use std::path::Path;
4+
5+
/// Load environment variables from .env file if it exists.
6+
/// Returns a HashMap of key-value pairs.
7+
/// If the .env file doesn't exist, returns an empty HashMap (no error).
8+
pub fn load_env_file(dir: &Path) -> Result<HashMap<String, String>> {
9+
let env_path = dir.join(".env");
10+
11+
if !env_path.exists() {
12+
return Ok(HashMap::new());
13+
}
14+
15+
let content = std::fs::read_to_string(&env_path)?;
16+
let mut vars = HashMap::new();
17+
18+
for line in content.lines() {
19+
let line = line.trim();
20+
21+
// Skip empty lines and comments
22+
if line.is_empty() || line.starts_with('#') {
23+
continue;
24+
}
25+
26+
// Parse KEY=VALUE
27+
if let Some((key, value)) = line.split_once('=') {
28+
let key = key.trim().to_string();
29+
let value = value.trim().to_string();
30+
31+
// Remove quotes from value if present
32+
let value = if (value.starts_with('"') && value.ends_with('"'))
33+
|| (value.starts_with('\'') && value.ends_with('\''))
34+
{
35+
value[1..value.len() - 1].to_string()
36+
} else {
37+
value
38+
};
39+
40+
vars.insert(key, value);
41+
}
42+
}
43+
44+
Ok(vars)
45+
}
46+
47+
#[cfg(test)]
48+
mod tests {
49+
use super::*;
50+
use std::fs;
51+
use tempfile::TempDir;
52+
53+
#[test]
54+
fn test_load_env_file_exists() {
55+
let dir = TempDir::new().unwrap();
56+
let env_path = dir.path().join(".env");
57+
fs::write(&env_path, "FOO=bar\nBAZ=qux").unwrap();
58+
59+
let vars = load_env_file(dir.path()).unwrap();
60+
assert_eq!(vars.get("FOO"), Some(&"bar".to_string()));
61+
assert_eq!(vars.get("BAZ"), Some(&"qux".to_string()));
62+
}
63+
64+
#[test]
65+
fn test_load_env_file_missing() {
66+
let dir = TempDir::new().unwrap();
67+
let vars = load_env_file(dir.path()).unwrap();
68+
assert!(vars.is_empty());
69+
}
70+
71+
#[test]
72+
fn test_load_env_with_quotes() {
73+
let dir = TempDir::new().unwrap();
74+
let env_path = dir.path().join(".env");
75+
fs::write(&env_path, "QUOTED=\"hello world\"\nSINGLE='test'").unwrap();
76+
77+
let vars = load_env_file(dir.path()).unwrap();
78+
assert_eq!(vars.get("QUOTED"), Some(&"hello world".to_string()));
79+
assert_eq!(vars.get("SINGLE"), Some(&"test".to_string()));
80+
}
81+
82+
#[test]
83+
fn test_load_env_with_comments() {
84+
let dir = TempDir::new().unwrap();
85+
let env_path = dir.path().join(".env");
86+
fs::write(&env_path, "# Comment\nKEY=value\n# Another comment\nFOO=bar").unwrap();
87+
88+
let vars = load_env_file(dir.path()).unwrap();
89+
assert_eq!(vars.len(), 2);
90+
assert_eq!(vars.get("KEY"), Some(&"value".to_string()));
91+
assert_eq!(vars.get("FOO"), Some(&"bar".to_string()));
92+
}
93+
94+
#[test]
95+
fn test_load_env_with_empty_lines() {
96+
let dir = TempDir::new().unwrap();
97+
let env_path = dir.path().join(".env");
98+
fs::write(&env_path, "KEY1=value1\n\nKEY2=value2\n\n").unwrap();
99+
100+
let vars = load_env_file(dir.path()).unwrap();
101+
assert_eq!(vars.len(), 2);
102+
assert_eq!(vars.get("KEY1"), Some(&"value1".to_string()));
103+
assert_eq!(vars.get("KEY2"), Some(&"value2".to_string()));
104+
}
105+
106+
#[test]
107+
fn test_load_env_with_spaces() {
108+
let dir = TempDir::new().unwrap();
109+
let env_path = dir.path().join(".env");
110+
fs::write(&env_path, "KEY = value with spaces").unwrap();
111+
112+
let vars = load_env_file(dir.path()).unwrap();
113+
assert_eq!(vars.get("KEY"), Some(&"value with spaces".to_string()));
114+
}
115+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod config;
2+
mod env;
23
mod git;
34
mod scheduler;
45

src/scheduler/executor.rs

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,28 @@ use tokio::process::Command;
44
use tokio::time::sleep;
55

66
use crate::config::Job;
7+
use crate::env;
78
use crate::git;
89

910
use super::backoff::{calculate_backoff, generate_jitter};
1011

1112
pub fn resolve_work_dir(sot_path: &PathBuf, job_id: &str, working_dir: &Option<String>) -> PathBuf {
1213
let job_dir = git::get_job_dir(sot_path, job_id);
1314
match working_dir {
14-
Some(dir) => job_dir.join(dir),
15+
Some(dir) => {
16+
let work_path = job_dir.join(dir);
17+
// Canonicalize to resolve .. and symlinks, then verify path is within job_dir
18+
match (work_path.canonicalize(), job_dir.canonicalize()) {
19+
(Ok(resolved), Ok(base)) if resolved.starts_with(&base) => resolved,
20+
_ => {
21+
eprintln!(
22+
"[job:{}] Invalid working_dir '{}': path traversal or non-existent",
23+
job_id, dir
24+
);
25+
job_dir
26+
}
27+
}
28+
}
1529
None => job_dir,
1630
}
1731
}
@@ -32,9 +46,11 @@ pub async fn execute_job(job: &Job, work_dir: &PathBuf) {
3246

3347
for attempt in 0..max_attempts {
3448
if attempt > 0 {
35-
let delay = calculate_backoff(job.retry.as_ref().unwrap(), attempt - 1);
36-
println!("{} Retry {}/{} after {:?}", tag, attempt, max_attempts - 1, delay);
37-
sleep(delay).await;
49+
if let Some(retry) = job.retry.as_ref() {
50+
let delay = calculate_backoff(retry, attempt - 1);
51+
println!("{} Retry {}/{} after {:?}", tag, attempt, max_attempts - 1, delay);
52+
sleep(delay).await;
53+
}
3854
}
3955

4056
println!("{} Starting '{}'", tag, job.name);
@@ -54,14 +70,27 @@ pub async fn execute_job(job: &Job, work_dir: &PathBuf) {
5470
}
5571

5672
async fn run_command(job: &Job, work_dir: &PathBuf) -> CommandResult {
57-
let child = match Command::new("sh")
58-
.args(["-c", &job.command])
73+
// Load .env file if it exists
74+
let env_vars = match env::load_env_file(work_dir) {
75+
Ok(vars) => vars,
76+
Err(e) => {
77+
return CommandResult::ExecError(format!("Failed to load .env file: {}", e));
78+
}
79+
};
80+
81+
let mut cmd = Command::new("sh");
82+
cmd.args(["-c", &job.command])
5983
.current_dir(work_dir)
6084
.stdout(std::process::Stdio::piped())
6185
.stderr(std::process::Stdio::piped())
62-
.kill_on_drop(true)
63-
.spawn()
64-
{
86+
.kill_on_drop(true);
87+
88+
// Apply environment variables from .env file
89+
for (key, value) in env_vars {
90+
cmd.env(key, value);
91+
}
92+
93+
let child = match cmd.spawn() {
6594
Ok(c) => c,
6695
Err(e) => return CommandResult::ExecError(e.to_string()),
6796
};
@@ -141,6 +170,8 @@ mod tests {
141170
retry: None,
142171
working_dir: None,
143172
jitter: None,
173+
enabled: true,
174+
timezone: None,
144175
}
145176
}
146177

@@ -195,4 +226,22 @@ mod tests {
195226
execute_job(&job, &dir.path().to_path_buf()).await;
196227
assert!(start.elapsed() < Duration::from_secs(1));
197228
}
229+
230+
#[tokio::test]
231+
async fn job_with_env_file() {
232+
let dir = tempdir().unwrap();
233+
let env_path = dir.path().join(".env");
234+
std::fs::write(&env_path, "TEST_VAR=hello\nOTHER_VAR=world").unwrap();
235+
236+
let job = make_job("echo $TEST_VAR $OTHER_VAR", 10);
237+
execute_job(&job, &dir.path().to_path_buf()).await;
238+
}
239+
240+
#[tokio::test]
241+
async fn job_without_env_file() {
242+
let dir = tempdir().unwrap();
243+
// No .env file created - should work fine
244+
let job = make_job("echo no env file", 10);
245+
execute_job(&job, &dir.path().to_path_buf()).await;
246+
}
198247
}

0 commit comments

Comments
 (0)