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")]
165 pub oneshot: bool,
166 #[serde(default)]
173 pub proxy_idle_timeout_ms: Option<u64>,
174 #[serde(skip_serializing_if = "Option::is_none", default)]
184 pub last_cron_run: Option<chrono::DateTime<chrono::Local>>,
185 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
189 pub no_shell: bool,
190 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
198 pub scheduled_from_config: bool,
199}
200
201#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
202pub struct RunOptions {
203 pub id: DaemonId,
204 pub cmd: Vec<String>,
205 #[serde(skip_serializing_if = "Option::is_none", default)]
208 pub run: Option<String>,
209 pub force: bool,
210 pub shell_pid: Option<u32>,
211 pub dir: Dir,
212 pub autostop: bool,
213 pub cron_schedule: Option<String>,
214 pub cron_retrigger: Option<CronRetrigger>,
215 pub cron_immediate: Option<bool>,
216 pub retry: Retry,
217 pub retry_count: u32,
218 pub ready_delay: Option<u64>,
219 pub ready_output: Option<ReadyOutput>,
220 pub ready_http: Option<ReadyHttp>,
221 pub ready_port: Option<ReadyPort>,
222 pub ready_cmd: Option<ReadyCmd>,
223 pub health_cmd: Option<HealthCmd>,
224 pub health_http: Option<HealthHttp>,
225 pub health_port: Option<HealthPort>,
226 pub port: Option<PortConfig>,
227 pub wait_ready: bool,
228 #[serde(skip_serializing_if = "Vec::is_empty", default)]
229 pub depends: Vec<DaemonId>,
230 #[serde(skip_serializing_if = "Option::is_none", default)]
231 pub env: Option<IndexMap<String, String>>,
232 #[serde(skip_serializing_if = "Vec::is_empty", default)]
233 pub watch: Vec<String>,
234 #[serde(default)]
235 pub watch_mode: WatchMode,
236 #[serde(skip_serializing_if = "Option::is_none", default)]
237 pub watch_base_dir: Option<PathBuf>,
238 #[serde(skip_serializing_if = "Option::is_none", default)]
243 pub mise: Option<bool>,
244 #[serde(skip_serializing_if = "Option::is_none", default)]
246 pub slug: Option<String>,
247 #[serde(skip_serializing_if = "Option::is_none", default)]
249 pub proxy: Option<bool>,
250 #[serde(skip_serializing_if = "Option::is_none", default)]
252 pub user: Option<String>,
253 #[serde(skip_serializing_if = "Option::is_none", default)]
255 pub memory_limit: Option<MemoryLimit>,
256 #[serde(skip_serializing_if = "Option::is_none", default)]
258 pub cpu_limit: Option<CpuLimit>,
259 #[serde(skip_serializing_if = "Option::is_none", default)]
261 pub stop_signal: Option<StopConfig>,
262 #[serde(skip_serializing_if = "Option::is_none", default)]
264 pub archive_hook: Option<String>,
265 #[serde(skip_serializing_if = "Option::is_none", default)]
267 pub log_format: Option<String>,
268 #[serde(skip_serializing_if = "Option::is_none", default)]
270 pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
271 #[serde(skip_serializing_if = "Option::is_none", default)]
273 pub pty: Option<bool>,
274 #[serde(default)]
281 pub oneshot: bool,
282 #[serde(skip_serializing_if = "Option::is_none", default)]
290 pub oneshot_wait: Option<OneshotWait>,
291 #[serde(default)]
297 pub on_directory_enter: bool,
298 #[serde(default)]
305 pub proxy_idle_timeout_ms: Option<u64>,
306 #[serde(default)]
316 pub cron_started: bool,
317 #[serde(default)]
322 pub no_shell: bool,
323 #[serde(skip)]
326 pub requested_by_client: bool,
327}
328
329impl Daemon {
330 pub fn next_cron_run(
340 &self,
341 now: chrono::DateTime<chrono::Local>,
342 ) -> Option<chrono::DateTime<chrono::Local>> {
343 use std::str::FromStr;
344 let schedule = cron::Schedule::from_str(self.cron_schedule.as_ref()?).ok()?;
345 let anchor = match self.last_cron_triggered {
346 Some(t) => t,
347 None if self.cron_immediate.unwrap_or(false) => now - chrono::Duration::seconds(10),
350 None => now,
351 };
352 schedule.after(&anchor).next()
353 }
354
355 pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
360 let on_output_hook = self
365 .dir
366 .as_deref()
367 .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
368 .or_else(|| crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces().ok())
369 .and_then(|pt| {
370 pt.daemons
371 .get(&self.id)
372 .and_then(|d| d.hooks.as_ref())
373 .and_then(|h| h.on_output.clone())
374 });
375
376 RunOptions {
377 id: self.id.clone(),
378 cmd,
379 run: self.run.clone(),
380 force: false,
381 shell_pid: self.shell_pid,
382 dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
383 autostop: self.autostop,
384 oneshot: self.oneshot,
385 oneshot_wait: None,
389 on_directory_enter: false,
390 proxy_idle_timeout_ms: self.proxy_idle_timeout_ms,
392 cron_started: false,
395 no_shell: self.no_shell,
396 requested_by_client: false,
398 cron_schedule: self.cron_schedule.clone(),
399 cron_retrigger: self.cron_retrigger,
400 cron_immediate: self.cron_immediate,
401 retry: self.retry,
402 retry_count: self.retry_count,
403 ready_delay: self.ready_delay,
404 ready_output: self.ready_output.clone(),
405 ready_http: self.ready_http.clone(),
406 ready_port: self.ready_port.clone(),
407 ready_cmd: self.ready_cmd.clone(),
408 health_cmd: self.health_cmd.clone(),
409 health_http: self.health_http.clone(),
410 health_port: self.health_port.clone(),
411 port: self.port.clone(),
412 wait_ready: false,
413 depends: self.depends.clone(),
414 env: self.env.clone(),
415 watch: self.watch.clone(),
416 watch_mode: self.watch_mode,
417 watch_base_dir: self.watch_base_dir.clone(),
418 mise: self.mise,
419 slug: self.slug.clone(),
420 proxy: self.proxy,
421 user: self.user.clone(),
422 memory_limit: self.memory_limit,
423 cpu_limit: self.cpu_limit,
424 stop_signal: self.stop_signal,
425 archive_hook: self.archive_hook.clone(),
426 log_format: self.log_format.clone(),
427 on_output_hook,
428 pty: self.pty,
429 }
430 }
431}
432
433impl Display for Daemon {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 write!(f, "{}", self.id.qualified())
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use chrono::TimeZone;
443
444 fn at(h: u32, m: u32, sec: u32) -> chrono::DateTime<chrono::Local> {
445 chrono::Local
446 .with_ymd_and_hms(2026, 9, 21, h, m, sec)
447 .unwrap()
448 }
449
450 fn daily_3am(last_triggered: Option<chrono::DateTime<chrono::Local>>) -> Daemon {
452 Daemon {
453 cron_schedule: Some("0 0 3 * * *".to_string()),
454 last_cron_triggered: last_triggered,
455 ..Daemon::default()
456 }
457 }
458
459 #[test]
460 fn next_cron_run_is_none_without_a_schedule() {
461 assert!(Daemon::default().next_cron_run(at(7, 0, 0)).is_none());
462 }
463
464 #[test]
467 fn next_cron_run_is_none_for_an_invalid_schedule() {
468 let d = Daemon {
469 cron_schedule: Some("not a cron expression".to_string()),
470 ..Daemon::default()
471 };
472 assert!(d.next_cron_run(at(7, 0, 0)).is_none());
473 }
474
475 #[test]
476 fn next_cron_run_follows_the_last_tick() {
477 let d = daily_3am(Some(at(3, 0, 4)));
478 assert_eq!(
479 d.next_cron_run(at(7, 0, 0)),
480 Some(
481 chrono::Local
482 .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
483 .unwrap()
484 )
485 );
486 }
487
488 #[test]
492 fn next_cron_run_reports_a_missed_window_as_past() {
493 let d = daily_3am(Some(
494 chrono::Local
495 .with_ymd_and_hms(2026, 9, 20, 3, 0, 0)
496 .unwrap(),
497 ));
498 let next = d.next_cron_run(at(7, 0, 0)).unwrap();
499 assert_eq!(next, at(3, 0, 0));
500 assert!(next < at(7, 0, 0));
501 }
502
503 #[test]
506 fn next_cron_run_skips_the_current_window_without_immediate() {
507 let d = daily_3am(None);
508 assert_eq!(
509 d.next_cron_run(at(2, 59, 0)),
510 Some(at(3, 0, 0)),
511 "a window still ahead of now is reported as-is"
512 );
513 assert_eq!(
514 d.next_cron_run(at(3, 0, 30)),
515 Some(
516 chrono::Local
517 .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
518 .unwrap()
519 ),
520 "a window that just passed is not claimed: immediate=false skips it"
521 );
522 }
523
524 #[test]
527 fn next_cron_run_honors_the_immediate_lookback() {
528 let d = Daemon {
529 cron_immediate: Some(true),
530 ..daily_3am(None)
531 };
532 assert_eq!(d.next_cron_run(at(3, 0, 5)), Some(at(3, 0, 0)));
533 }
534
535 #[test]
536 fn test_valid_daemon_ids() {
537 assert!(is_valid_daemon_id("myapp"));
539 assert!(is_valid_daemon_id("my-app"));
540 assert!(is_valid_daemon_id("my_app"));
541 assert!(is_valid_daemon_id("my.app"));
542 assert!(is_valid_daemon_id("MyApp123"));
543
544 assert!(is_valid_daemon_id("project/api"));
546 assert!(is_valid_daemon_id("global/web"));
547 assert!(is_valid_daemon_id("my-project/my-app"));
548 }
549
550 #[test]
551 fn test_invalid_daemon_ids() {
552 assert!(!is_valid_daemon_id(""));
554
555 assert!(!is_valid_daemon_id("a/b/c"));
557 assert!(!is_valid_daemon_id("../etc/passwd"));
558
559 assert!(!is_valid_daemon_id("/api"));
561 assert!(!is_valid_daemon_id("project/"));
562
563 assert!(!is_valid_daemon_id("foo\\bar"));
565
566 assert!(!is_valid_daemon_id(".."));
568 assert!(!is_valid_daemon_id("foo..bar"));
569
570 assert!(!is_valid_daemon_id("my--app"));
572 assert!(!is_valid_daemon_id("project--api"));
573 assert!(!is_valid_daemon_id("--app"));
574 assert!(!is_valid_daemon_id("app--"));
575
576 assert!(!is_valid_daemon_id("my app"));
578 assert!(!is_valid_daemon_id(" myapp"));
579 assert!(!is_valid_daemon_id("myapp "));
580
581 assert!(!is_valid_daemon_id("."));
583
584 assert!(!is_valid_daemon_id("my\x00app"));
586 assert!(!is_valid_daemon_id("my\napp"));
587 assert!(!is_valid_daemon_id("my\tapp"));
588
589 assert!(!is_valid_daemon_id("myäpp"));
591 assert!(!is_valid_daemon_id("приложение"));
592
593 assert!(!is_valid_daemon_id("app@host"));
595 assert!(!is_valid_daemon_id("app:8080"));
596 }
597}