Skip to main content

pitchfork_cli/
daemon.rs

1use crate::daemon_id::DaemonId;
2use crate::daemon_status::DaemonStatus;
3use crate::pitchfork_toml::{
4    CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, PortConfig,
5    ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig, WatchMode,
6};
7use indexmap::IndexMap;
8use std::fmt::Display;
9use std::path::PathBuf;
10
11/// Validates a daemon ID to ensure it's safe for use in file paths and IPC.
12///
13/// A valid daemon ID:
14/// - Is not empty
15/// - Does not contain backslashes (`\`)
16/// - Does not contain parent directory references (`..`)
17/// - Does not contain spaces
18/// - Does not contain `--` (reserved for path encoding of `/`)
19/// - Is not `.` (current directory)
20/// - Contains only printable ASCII characters
21/// - If qualified (contains `/`), has exactly one `/` separating namespace and short ID
22///
23/// Format: `[namespace/]short_id`
24/// - Qualified: `project/api`, `global/web`
25/// - Short: `api`, `web`
26///
27/// This validation prevents path traversal attacks when daemon IDs are used
28/// to construct log file paths or other filesystem operations.
29pub fn is_valid_daemon_id(id: &str) -> bool {
30    if id.contains('/') {
31        DaemonId::parse(id).is_ok()
32    } else {
33        DaemonId::try_new("global", id).is_ok()
34    }
35}
36
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
38pub struct Daemon {
39    pub id: DaemonId,
40    pub title: Option<String>,
41    pub pid: Option<u32>,
42    /// High-resolution kernel start token recorded at spawn. Together with
43    /// `pid` this identifies the process across a supervisor crash: a recycled
44    /// PID has a different token, so orphan cleanup can tell a genuine orphan
45    /// from an unrelated process.
46    #[serde(skip_serializing_if = "Option::is_none", default)]
47    pub start_time: Option<u64>,
48    /// System boot time (seconds since epoch) recorded at spawn. Lets orphan
49    /// reconciliation tell a daemon that died under a crashed supervisor
50    /// during this boot from one whose process died with the machine, which
51    /// need different terminal states. Unlike `start_time` this is a
52    /// wall-clock value comparable across processes and platforms.
53    #[serde(skip_serializing_if = "Option::is_none", default)]
54    pub boot_time: Option<u64>,
55    pub shell_pid: Option<u32>,
56    pub status: DaemonStatus,
57    pub dir: Option<PathBuf>,
58    #[serde(skip_serializing_if = "Option::is_none", default)]
59    pub cmd: Option<Vec<String>>,
60    /// Original shell command string, persisted for retry/watch restarts.
61    #[serde(skip_serializing_if = "Option::is_none", default)]
62    pub run: Option<String>,
63    pub autostop: bool,
64    #[serde(skip_serializing_if = "Option::is_none", default)]
65    pub cron_schedule: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none", default)]
67    pub cron_retrigger: Option<CronRetrigger>,
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    pub cron_immediate: Option<bool>,
70    #[serde(skip_serializing_if = "Option::is_none", default)]
71    pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub last_exit_success: Option<bool>,
74    #[serde(default)]
75    pub retry: Retry,
76    #[serde(default)]
77    pub retry_count: u32,
78    #[serde(skip_serializing_if = "Option::is_none", default)]
79    pub ready_delay: Option<u64>,
80    #[serde(skip_serializing_if = "Option::is_none", default)]
81    pub ready_output: Option<ReadyOutput>,
82    #[serde(skip_serializing_if = "Option::is_none", default)]
83    pub ready_http: Option<ReadyHttp>,
84    #[serde(skip_serializing_if = "Option::is_none", default)]
85    pub ready_port: Option<ReadyPort>,
86    #[serde(skip_serializing_if = "Option::is_none", default)]
87    pub ready_cmd: Option<ReadyCmd>,
88    #[serde(skip_serializing_if = "Option::is_none", default)]
89    pub health_cmd: Option<HealthCmd>,
90    #[serde(skip_serializing_if = "Option::is_none", default)]
91    pub health_http: Option<HealthHttp>,
92    #[serde(skip_serializing_if = "Option::is_none", default)]
93    pub health_port: Option<HealthPort>,
94    /// Port configuration (expected ports and auto-bump settings)
95    #[serde(skip_serializing_if = "Option::is_none", default)]
96    pub port: Option<PortConfig>,
97    /// Resolved ports actually used after auto-bump (may differ from expected)
98    #[serde(skip_serializing_if = "Vec::is_empty", default)]
99    pub resolved_port: Vec<u16>,
100    /// The first port the process is actually listening on (detected at runtime via listeners crate).
101    /// This is the source of truth for the reverse proxy. Cleared when the daemon stops.
102    #[serde(skip_serializing_if = "Option::is_none", default)]
103    pub active_port: Option<u16>,
104    /// Optional stable slug alias for this daemon (used in proxy URLs and CLI commands).
105    #[serde(skip_serializing_if = "Option::is_none", default)]
106    pub slug: Option<String>,
107    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub proxy: Option<bool>,
110    #[serde(skip_serializing_if = "Vec::is_empty", default)]
111    pub depends: Vec<DaemonId>,
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub env: Option<IndexMap<String, String>>,
114    #[serde(skip_serializing_if = "Vec::is_empty", default)]
115    pub watch: Vec<String>,
116    #[serde(default)]
117    pub watch_mode: WatchMode,
118    #[serde(skip_serializing_if = "Option::is_none", default)]
119    pub watch_base_dir: Option<PathBuf>,
120    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
121    ///
122    /// # Schema compatibility note
123    /// This field changed from `bool` to `Option<bool>` with `skip_serializing_if = "Option::is_none"`.
124    /// - **Upgrade (old → new):** safe — old files contain `mise = true/false`, which deserialize
125    ///   correctly as `Some(true)` / `Some(false)`.
126    /// - **Downgrade (new → old):** if `mise` is `None` (inherit global), the key is omitted from
127    ///   the state file. An old binary reads the missing key as `false`, ignoring `general.mise = true`.
128    ///   Any daemon that relied on the global setting would silently stop using mise after a downgrade.
129    #[serde(skip_serializing_if = "Option::is_none", default)]
130    pub mise: Option<bool>,
131    /// Unix user to run this daemon as.
132    #[serde(skip_serializing_if = "Option::is_none", default)]
133    pub user: Option<String>,
134    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
135    #[serde(skip_serializing_if = "Option::is_none", default)]
136    pub memory_limit: Option<MemoryLimit>,
137    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
138    #[serde(skip_serializing_if = "Option::is_none", default)]
139    pub cpu_limit: Option<CpuLimit>,
140    /// Unix signal to send for graceful shutdown (default: SIGTERM)
141    #[serde(skip_serializing_if = "Option::is_none", default)]
142    pub stop_signal: Option<StopConfig>,
143    /// Archive hook command invoked before retention prunes this daemon's logs.
144    #[serde(skip_serializing_if = "Option::is_none", default)]
145    pub archive_hook: Option<String>,
146    /// Log format for this daemon.
147    #[serde(skip_serializing_if = "Option::is_none", default)]
148    pub log_format: Option<String>,
149    /// Allocate a pseudo-terminal for the daemon process.
150    #[serde(skip_serializing_if = "Option::is_none", default)]
151    pub pty: Option<bool>,
152    /// True for daemons auto-registered from config by the cron watcher,
153    /// not yet started. Treated as "available" by list/status/stats.
154    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
155    pub config_registered: bool,
156}
157
158#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
159pub struct RunOptions {
160    pub id: DaemonId,
161    pub cmd: Vec<String>,
162    /// Original shell command string (from config `run`), passed verbatim to the shell.
163    /// Falls back to joining `cmd` when None (e.g. ad-hoc `pitchfork run -- cmd args`).
164    #[serde(skip_serializing_if = "Option::is_none", default)]
165    pub run: Option<String>,
166    pub force: bool,
167    pub shell_pid: Option<u32>,
168    pub dir: Dir,
169    pub autostop: bool,
170    pub cron_schedule: Option<String>,
171    pub cron_retrigger: Option<CronRetrigger>,
172    pub cron_immediate: Option<bool>,
173    pub retry: Retry,
174    pub retry_count: u32,
175    pub ready_delay: Option<u64>,
176    pub ready_output: Option<ReadyOutput>,
177    pub ready_http: Option<ReadyHttp>,
178    pub ready_port: Option<ReadyPort>,
179    pub ready_cmd: Option<ReadyCmd>,
180    pub health_cmd: Option<HealthCmd>,
181    pub health_http: Option<HealthHttp>,
182    pub health_port: Option<HealthPort>,
183    pub port: Option<PortConfig>,
184    pub wait_ready: bool,
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    pub depends: Vec<DaemonId>,
187    #[serde(skip_serializing_if = "Option::is_none", default)]
188    pub env: Option<IndexMap<String, String>>,
189    #[serde(skip_serializing_if = "Vec::is_empty", default)]
190    pub watch: Vec<String>,
191    #[serde(default)]
192    pub watch_mode: WatchMode,
193    #[serde(skip_serializing_if = "Option::is_none", default)]
194    pub watch_base_dir: Option<PathBuf>,
195    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
196    ///
197    /// # Schema compatibility note
198    /// See `Daemon::mise` for downgrade implications when this field is `None`.
199    #[serde(skip_serializing_if = "Option::is_none", default)]
200    pub mise: Option<bool>,
201    /// Optional stable slug alias for this daemon.
202    #[serde(skip_serializing_if = "Option::is_none", default)]
203    pub slug: Option<String>,
204    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub proxy: Option<bool>,
207    /// Unix user to run this daemon as.
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub user: Option<String>,
210    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
211    #[serde(skip_serializing_if = "Option::is_none", default)]
212    pub memory_limit: Option<MemoryLimit>,
213    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
214    #[serde(skip_serializing_if = "Option::is_none", default)]
215    pub cpu_limit: Option<CpuLimit>,
216    /// Unix signal to send for graceful shutdown (default: SIGTERM)
217    #[serde(skip_serializing_if = "Option::is_none", default)]
218    pub stop_signal: Option<StopConfig>,
219    /// Archive hook command invoked before retention prunes this daemon's logs.
220    #[serde(skip_serializing_if = "Option::is_none", default)]
221    pub archive_hook: Option<String>,
222    /// Log format for this daemon: `json`, `logfmt`, `auto`, or `text`.
223    #[serde(skip_serializing_if = "Option::is_none", default)]
224    pub log_format: Option<String>,
225    /// Hook triggered when the daemon produces matching output
226    #[serde(skip_serializing_if = "Option::is_none", default)]
227    pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
228    /// Allocate a pseudo-terminal for the daemon process.
229    #[serde(skip_serializing_if = "Option::is_none", default)]
230    pub pty: Option<bool>,
231}
232
233impl Daemon {
234    /// Build RunOptions from persisted daemon state.
235    ///
236    /// Carries over all configuration fields from the daemon state.
237    /// Callers can override specific fields on the returned value.
238    pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
239        // Re-read on_output_hook from fresh config so restarts (retry, watch,
240        // cron) always pick up the current hook configuration.
241        // Use daemon.dir if available to handle daemons started via slugs
242        // whose project directory is not in the supervisor's cwd ancestry.
243        let on_output_hook = self
244            .dir
245            .as_deref()
246            .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
247            .or_else(|| crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces().ok())
248            .and_then(|pt| {
249                pt.daemons
250                    .get(&self.id)
251                    .and_then(|d| d.hooks.as_ref())
252                    .and_then(|h| h.on_output.clone())
253            });
254
255        RunOptions {
256            id: self.id.clone(),
257            cmd,
258            run: self.run.clone(),
259            force: false,
260            shell_pid: self.shell_pid,
261            dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
262            autostop: self.autostop,
263            cron_schedule: self.cron_schedule.clone(),
264            cron_retrigger: self.cron_retrigger,
265            cron_immediate: self.cron_immediate,
266            retry: self.retry,
267            retry_count: self.retry_count,
268            ready_delay: self.ready_delay,
269            ready_output: self.ready_output.clone(),
270            ready_http: self.ready_http.clone(),
271            ready_port: self.ready_port.clone(),
272            ready_cmd: self.ready_cmd.clone(),
273            health_cmd: self.health_cmd.clone(),
274            health_http: self.health_http.clone(),
275            health_port: self.health_port.clone(),
276            port: self.port.clone(),
277            wait_ready: false,
278            depends: self.depends.clone(),
279            env: self.env.clone(),
280            watch: self.watch.clone(),
281            watch_mode: self.watch_mode,
282            watch_base_dir: self.watch_base_dir.clone(),
283            mise: self.mise,
284            slug: self.slug.clone(),
285            proxy: self.proxy,
286            user: self.user.clone(),
287            memory_limit: self.memory_limit,
288            cpu_limit: self.cpu_limit,
289            stop_signal: self.stop_signal,
290            archive_hook: self.archive_hook.clone(),
291            log_format: self.log_format.clone(),
292            on_output_hook,
293            pty: self.pty,
294        }
295    }
296}
297
298impl Display for Daemon {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        write!(f, "{}", self.id.qualified())
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_valid_daemon_ids() {
310        // Short IDs
311        assert!(is_valid_daemon_id("myapp"));
312        assert!(is_valid_daemon_id("my-app"));
313        assert!(is_valid_daemon_id("my_app"));
314        assert!(is_valid_daemon_id("my.app"));
315        assert!(is_valid_daemon_id("MyApp123"));
316
317        // Qualified IDs (namespace/short_id)
318        assert!(is_valid_daemon_id("project/api"));
319        assert!(is_valid_daemon_id("global/web"));
320        assert!(is_valid_daemon_id("my-project/my-app"));
321    }
322
323    #[test]
324    fn test_invalid_daemon_ids() {
325        // Empty
326        assert!(!is_valid_daemon_id(""));
327
328        // Multiple slashes (invalid qualified format)
329        assert!(!is_valid_daemon_id("a/b/c"));
330        assert!(!is_valid_daemon_id("../etc/passwd"));
331
332        // Invalid qualified format (empty parts)
333        assert!(!is_valid_daemon_id("/api"));
334        assert!(!is_valid_daemon_id("project/"));
335
336        // Backslashes
337        assert!(!is_valid_daemon_id("foo\\bar"));
338
339        // Parent directory reference
340        assert!(!is_valid_daemon_id(".."));
341        assert!(!is_valid_daemon_id("foo..bar"));
342
343        // Double dash (reserved for path encoding)
344        assert!(!is_valid_daemon_id("my--app"));
345        assert!(!is_valid_daemon_id("project--api"));
346        assert!(!is_valid_daemon_id("--app"));
347        assert!(!is_valid_daemon_id("app--"));
348
349        // Spaces
350        assert!(!is_valid_daemon_id("my app"));
351        assert!(!is_valid_daemon_id(" myapp"));
352        assert!(!is_valid_daemon_id("myapp "));
353
354        // Current directory
355        assert!(!is_valid_daemon_id("."));
356
357        // Control characters
358        assert!(!is_valid_daemon_id("my\x00app"));
359        assert!(!is_valid_daemon_id("my\napp"));
360        assert!(!is_valid_daemon_id("my\tapp"));
361
362        // Non-ASCII
363        assert!(!is_valid_daemon_id("myäpp"));
364        assert!(!is_valid_daemon_id("приложение"));
365
366        // Unsupported punctuation under DaemonId rules
367        assert!(!is_valid_daemon_id("app@host"));
368        assert!(!is_valid_daemon_id("app:8080"));
369    }
370}