1use crate::config_types::OneshotWait;
2use crate::daemon_id::DaemonId;
3use crate::daemon_status::DaemonStatus;
4use crate::pitchfork_toml::{
5 CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, PortConfig,
6 ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig, WatchMode,
7};
8use indexmap::IndexMap;
9use std::fmt::Display;
10use std::path::PathBuf;
11
12pub fn is_valid_daemon_id(id: &str) -> bool {
31 if id.contains('/') {
32 DaemonId::parse(id).is_ok()
33 } else {
34 DaemonId::try_new("global", id).is_ok()
35 }
36}
37
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
39pub struct Daemon {
40 pub id: DaemonId,
41 pub title: Option<String>,
42 pub pid: Option<u32>,
43 #[serde(skip_serializing_if = "Option::is_none", default)]
48 pub start_time: Option<u64>,
49 #[serde(skip_serializing_if = "Option::is_none", default)]
55 pub boot_time: Option<u64>,
56 pub shell_pid: Option<u32>,
57 pub status: DaemonStatus,
58 pub dir: Option<PathBuf>,
59 #[serde(skip_serializing_if = "Option::is_none", default)]
60 pub cmd: Option<Vec<String>>,
61 #[serde(skip_serializing_if = "Option::is_none", default)]
63 pub run: Option<String>,
64 pub autostop: bool,
65 #[serde(skip_serializing_if = "Option::is_none", default)]
66 pub cron_schedule: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none", default)]
68 pub cron_retrigger: Option<CronRetrigger>,
69 #[serde(skip_serializing_if = "Option::is_none", default)]
70 pub cron_immediate: Option<bool>,
71 #[serde(skip_serializing_if = "Option::is_none", default)]
72 pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
73 #[serde(skip_serializing_if = "Option::is_none", default)]
74 pub last_exit_success: Option<bool>,
75 #[serde(default)]
76 pub retry: Retry,
77 #[serde(default)]
78 pub retry_count: u32,
79 #[serde(skip_serializing_if = "Option::is_none", default)]
80 pub ready_delay: Option<u64>,
81 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub ready_output: Option<ReadyOutput>,
83 #[serde(skip_serializing_if = "Option::is_none", default)]
84 pub ready_http: Option<ReadyHttp>,
85 #[serde(skip_serializing_if = "Option::is_none", default)]
86 pub ready_port: Option<ReadyPort>,
87 #[serde(skip_serializing_if = "Option::is_none", default)]
88 pub ready_cmd: Option<ReadyCmd>,
89 #[serde(skip_serializing_if = "Option::is_none", default)]
90 pub health_cmd: Option<HealthCmd>,
91 #[serde(skip_serializing_if = "Option::is_none", default)]
92 pub health_http: Option<HealthHttp>,
93 #[serde(skip_serializing_if = "Option::is_none", default)]
94 pub health_port: Option<HealthPort>,
95 #[serde(skip_serializing_if = "Option::is_none", default)]
97 pub port: Option<PortConfig>,
98 #[serde(skip_serializing_if = "Vec::is_empty", default)]
100 pub resolved_port: Vec<u16>,
101 #[serde(skip_serializing_if = "Option::is_none", default)]
104 pub active_port: Option<u16>,
105 #[serde(skip_serializing_if = "Option::is_none", default)]
107 pub slug: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none", default)]
110 pub proxy: Option<bool>,
111 #[serde(skip_serializing_if = "Vec::is_empty", default)]
112 pub depends: Vec<DaemonId>,
113 #[serde(skip_serializing_if = "Option::is_none", default)]
114 pub env: Option<IndexMap<String, String>>,
115 #[serde(skip_serializing_if = "Vec::is_empty", default)]
116 pub watch: Vec<String>,
117 #[serde(default)]
118 pub watch_mode: WatchMode,
119 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub watch_base_dir: Option<PathBuf>,
121 #[serde(skip_serializing_if = "Option::is_none", default)]
131 pub mise: Option<bool>,
132 #[serde(skip_serializing_if = "Option::is_none", default)]
134 pub user: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none", default)]
137 pub memory_limit: Option<MemoryLimit>,
138 #[serde(skip_serializing_if = "Option::is_none", default)]
140 pub cpu_limit: Option<CpuLimit>,
141 #[serde(skip_serializing_if = "Option::is_none", default)]
143 pub stop_signal: Option<StopConfig>,
144 #[serde(skip_serializing_if = "Option::is_none", default)]
146 pub archive_hook: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none", default)]
149 pub log_format: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none", default)]
152 pub pty: Option<bool>,
153 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
156 pub config_registered: bool,
157 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
161 pub oneshot: bool,
162 #[serde(default)]
167 pub proxy_idle_timeout_ms: Option<u64>,
168 #[serde(skip_serializing_if = "Option::is_none", default)]
176 pub last_cron_run: Option<chrono::DateTime<chrono::Local>>,
177 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
179 pub no_shell: bool,
180 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
188 pub scheduled_from_config: bool,
189}
190
191#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
192pub struct RunOptions {
193 pub id: DaemonId,
194 pub cmd: Vec<String>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
198 pub run: Option<String>,
199 pub force: bool,
200 pub shell_pid: Option<u32>,
201 pub dir: Dir,
202 pub autostop: bool,
203 pub cron_schedule: Option<String>,
204 pub cron_retrigger: Option<CronRetrigger>,
205 pub cron_immediate: Option<bool>,
206 pub retry: Retry,
207 pub retry_count: u32,
208 pub ready_delay: Option<u64>,
209 pub ready_output: Option<ReadyOutput>,
210 pub ready_http: Option<ReadyHttp>,
211 pub ready_port: Option<ReadyPort>,
212 pub ready_cmd: Option<ReadyCmd>,
213 pub health_cmd: Option<HealthCmd>,
214 pub health_http: Option<HealthHttp>,
215 pub health_port: Option<HealthPort>,
216 pub port: Option<PortConfig>,
217 pub wait_ready: bool,
218 #[serde(skip_serializing_if = "Vec::is_empty", default)]
219 pub depends: Vec<DaemonId>,
220 #[serde(skip_serializing_if = "Option::is_none", default)]
221 pub env: Option<IndexMap<String, String>>,
222 #[serde(skip_serializing_if = "Vec::is_empty", default)]
223 pub watch: Vec<String>,
224 #[serde(default)]
225 pub watch_mode: WatchMode,
226 #[serde(skip_serializing_if = "Option::is_none", default)]
227 pub watch_base_dir: Option<PathBuf>,
228 #[serde(skip_serializing_if = "Option::is_none", default)]
233 pub mise: Option<bool>,
234 #[serde(skip_serializing_if = "Option::is_none", default)]
236 pub slug: Option<String>,
237 #[serde(skip_serializing_if = "Option::is_none", default)]
239 pub proxy: Option<bool>,
240 #[serde(skip_serializing_if = "Option::is_none", default)]
242 pub user: Option<String>,
243 #[serde(skip_serializing_if = "Option::is_none", default)]
245 pub memory_limit: Option<MemoryLimit>,
246 #[serde(skip_serializing_if = "Option::is_none", default)]
248 pub cpu_limit: Option<CpuLimit>,
249 #[serde(skip_serializing_if = "Option::is_none", default)]
251 pub stop_signal: Option<StopConfig>,
252 #[serde(skip_serializing_if = "Option::is_none", default)]
254 pub archive_hook: Option<String>,
255 #[serde(skip_serializing_if = "Option::is_none", default)]
257 pub log_format: Option<String>,
258 #[serde(skip_serializing_if = "Option::is_none", default)]
260 pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
261 #[serde(skip_serializing_if = "Option::is_none", default)]
263 pub pty: Option<bool>,
264 #[serde(default)]
266 pub oneshot: bool,
267 #[serde(skip_serializing_if = "Option::is_none", default)]
275 pub oneshot_wait: Option<OneshotWait>,
276 #[serde(default)]
282 pub on_directory_enter: bool,
283 #[serde(default)]
288 pub proxy_idle_timeout_ms: Option<u64>,
289 #[serde(default)]
296 pub cron_started: bool,
297 #[serde(default)]
300 pub no_shell: bool,
301 #[serde(skip)]
304 pub requested_by_client: bool,
305}
306
307impl Daemon {
308 pub fn next_cron_run(
318 &self,
319 now: chrono::DateTime<chrono::Local>,
320 ) -> Option<chrono::DateTime<chrono::Local>> {
321 use std::str::FromStr;
322 let schedule = cron::Schedule::from_str(self.cron_schedule.as_ref()?).ok()?;
323 let anchor = match self.last_cron_triggered {
324 Some(t) => t,
325 None if self.cron_immediate.unwrap_or(false) => now - chrono::Duration::seconds(10),
328 None => now,
329 };
330 schedule.after(&anchor).next()
331 }
332
333 pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
338 let on_output_hook = self
343 .dir
344 .as_deref()
345 .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
346 .or_else(|| crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces().ok())
347 .and_then(|pt| {
348 pt.daemons
349 .get(&self.id)
350 .and_then(|d| d.hooks.as_ref())
351 .and_then(|h| h.on_output.clone())
352 });
353
354 RunOptions {
355 id: self.id.clone(),
356 cmd,
357 run: self.run.clone(),
358 force: false,
359 shell_pid: self.shell_pid,
360 dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
361 autostop: self.autostop,
362 oneshot: self.oneshot,
363 oneshot_wait: None,
367 on_directory_enter: false,
368 proxy_idle_timeout_ms: self.proxy_idle_timeout_ms,
370 cron_started: false,
373 no_shell: self.no_shell,
374 requested_by_client: false,
376 cron_schedule: self.cron_schedule.clone(),
377 cron_retrigger: self.cron_retrigger,
378 cron_immediate: self.cron_immediate,
379 retry: self.retry,
380 retry_count: self.retry_count,
381 ready_delay: self.ready_delay,
382 ready_output: self.ready_output.clone(),
383 ready_http: self.ready_http.clone(),
384 ready_port: self.ready_port.clone(),
385 ready_cmd: self.ready_cmd.clone(),
386 health_cmd: self.health_cmd.clone(),
387 health_http: self.health_http.clone(),
388 health_port: self.health_port.clone(),
389 port: self.port.clone(),
390 wait_ready: false,
391 depends: self.depends.clone(),
392 env: self.env.clone(),
393 watch: self.watch.clone(),
394 watch_mode: self.watch_mode,
395 watch_base_dir: self.watch_base_dir.clone(),
396 mise: self.mise,
397 slug: self.slug.clone(),
398 proxy: self.proxy,
399 user: self.user.clone(),
400 memory_limit: self.memory_limit,
401 cpu_limit: self.cpu_limit,
402 stop_signal: self.stop_signal,
403 archive_hook: self.archive_hook.clone(),
404 log_format: self.log_format.clone(),
405 on_output_hook,
406 pty: self.pty,
407 }
408 }
409}
410
411impl Display for Daemon {
412 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413 write!(f, "{}", self.id.qualified())
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use chrono::TimeZone;
421
422 fn at(h: u32, m: u32, sec: u32) -> chrono::DateTime<chrono::Local> {
423 chrono::Local
424 .with_ymd_and_hms(2026, 9, 21, h, m, sec)
425 .unwrap()
426 }
427
428 fn daily_3am(last_triggered: Option<chrono::DateTime<chrono::Local>>) -> Daemon {
430 Daemon {
431 cron_schedule: Some("0 0 3 * * *".to_string()),
432 last_cron_triggered: last_triggered,
433 ..Daemon::default()
434 }
435 }
436
437 #[test]
438 fn next_cron_run_is_none_without_a_schedule() {
439 assert!(Daemon::default().next_cron_run(at(7, 0, 0)).is_none());
440 }
441
442 #[test]
445 fn next_cron_run_is_none_for_an_invalid_schedule() {
446 let d = Daemon {
447 cron_schedule: Some("not a cron expression".to_string()),
448 ..Daemon::default()
449 };
450 assert!(d.next_cron_run(at(7, 0, 0)).is_none());
451 }
452
453 #[test]
454 fn next_cron_run_follows_the_last_tick() {
455 let d = daily_3am(Some(at(3, 0, 4)));
456 assert_eq!(
457 d.next_cron_run(at(7, 0, 0)),
458 Some(
459 chrono::Local
460 .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
461 .unwrap()
462 )
463 );
464 }
465
466 #[test]
470 fn next_cron_run_reports_a_missed_window_as_past() {
471 let d = daily_3am(Some(
472 chrono::Local
473 .with_ymd_and_hms(2026, 9, 20, 3, 0, 0)
474 .unwrap(),
475 ));
476 let next = d.next_cron_run(at(7, 0, 0)).unwrap();
477 assert_eq!(next, at(3, 0, 0));
478 assert!(next < at(7, 0, 0));
479 }
480
481 #[test]
484 fn next_cron_run_skips_the_current_window_without_immediate() {
485 let d = daily_3am(None);
486 assert_eq!(
487 d.next_cron_run(at(2, 59, 0)),
488 Some(at(3, 0, 0)),
489 "a window still ahead of now is reported as-is"
490 );
491 assert_eq!(
492 d.next_cron_run(at(3, 0, 30)),
493 Some(
494 chrono::Local
495 .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
496 .unwrap()
497 ),
498 "a window that just passed is not claimed: immediate=false skips it"
499 );
500 }
501
502 #[test]
505 fn next_cron_run_honors_the_immediate_lookback() {
506 let d = Daemon {
507 cron_immediate: Some(true),
508 ..daily_3am(None)
509 };
510 assert_eq!(d.next_cron_run(at(3, 0, 5)), Some(at(3, 0, 0)));
511 }
512
513 #[test]
514 fn test_valid_daemon_ids() {
515 assert!(is_valid_daemon_id("myapp"));
517 assert!(is_valid_daemon_id("my-app"));
518 assert!(is_valid_daemon_id("my_app"));
519 assert!(is_valid_daemon_id("my.app"));
520 assert!(is_valid_daemon_id("MyApp123"));
521
522 assert!(is_valid_daemon_id("project/api"));
524 assert!(is_valid_daemon_id("global/web"));
525 assert!(is_valid_daemon_id("my-project/my-app"));
526 }
527
528 #[test]
529 fn test_invalid_daemon_ids() {
530 assert!(!is_valid_daemon_id(""));
532
533 assert!(!is_valid_daemon_id("a/b/c"));
535 assert!(!is_valid_daemon_id("../etc/passwd"));
536
537 assert!(!is_valid_daemon_id("/api"));
539 assert!(!is_valid_daemon_id("project/"));
540
541 assert!(!is_valid_daemon_id("foo\\bar"));
543
544 assert!(!is_valid_daemon_id(".."));
546 assert!(!is_valid_daemon_id("foo..bar"));
547
548 assert!(!is_valid_daemon_id("my--app"));
550 assert!(!is_valid_daemon_id("project--api"));
551 assert!(!is_valid_daemon_id("--app"));
552 assert!(!is_valid_daemon_id("app--"));
553
554 assert!(!is_valid_daemon_id("my app"));
556 assert!(!is_valid_daemon_id(" myapp"));
557 assert!(!is_valid_daemon_id("myapp "));
558
559 assert!(!is_valid_daemon_id("."));
561
562 assert!(!is_valid_daemon_id("my\x00app"));
564 assert!(!is_valid_daemon_id("my\napp"));
565 assert!(!is_valid_daemon_id("my\tapp"));
566
567 assert!(!is_valid_daemon_id("myäpp"));
569 assert!(!is_valid_daemon_id("приложение"));
570
571 assert!(!is_valid_daemon_id("app@host"));
573 assert!(!is_valid_daemon_id("app:8080"));
574 }
575}