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, MemoryLimit, PortConfig, ReadyCmd, ReadyHttp, ReadyOutput,
5    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    /// Port configuration (expected ports and auto-bump settings)
89    #[serde(skip_serializing_if = "Option::is_none", default)]
90    pub port: Option<PortConfig>,
91    /// Resolved ports actually used after auto-bump (may differ from expected)
92    #[serde(skip_serializing_if = "Vec::is_empty", default)]
93    pub resolved_port: Vec<u16>,
94    /// The first port the process is actually listening on (detected at runtime via listeners crate).
95    /// This is the source of truth for the reverse proxy. Cleared when the daemon stops.
96    #[serde(skip_serializing_if = "Option::is_none", default)]
97    pub active_port: Option<u16>,
98    /// Optional stable slug alias for this daemon (used in proxy URLs and CLI commands).
99    #[serde(skip_serializing_if = "Option::is_none", default)]
100    pub slug: Option<String>,
101    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
102    #[serde(skip_serializing_if = "Option::is_none", default)]
103    pub proxy: Option<bool>,
104    #[serde(skip_serializing_if = "Vec::is_empty", default)]
105    pub depends: Vec<DaemonId>,
106    #[serde(skip_serializing_if = "Option::is_none", default)]
107    pub env: Option<IndexMap<String, String>>,
108    #[serde(skip_serializing_if = "Vec::is_empty", default)]
109    pub watch: Vec<String>,
110    #[serde(default)]
111    pub watch_mode: WatchMode,
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub watch_base_dir: Option<PathBuf>,
114    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
115    ///
116    /// # Schema compatibility note
117    /// This field changed from `bool` to `Option<bool>` with `skip_serializing_if = "Option::is_none"`.
118    /// - **Upgrade (old → new):** safe — old files contain `mise = true/false`, which deserialize
119    ///   correctly as `Some(true)` / `Some(false)`.
120    /// - **Downgrade (new → old):** if `mise` is `None` (inherit global), the key is omitted from
121    ///   the state file. An old binary reads the missing key as `false`, ignoring `general.mise = true`.
122    ///   Any daemon that relied on the global setting would silently stop using mise after a downgrade.
123    #[serde(skip_serializing_if = "Option::is_none", default)]
124    pub mise: Option<bool>,
125    /// Unix user to run this daemon as.
126    #[serde(skip_serializing_if = "Option::is_none", default)]
127    pub user: Option<String>,
128    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
129    #[serde(skip_serializing_if = "Option::is_none", default)]
130    pub memory_limit: Option<MemoryLimit>,
131    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
132    #[serde(skip_serializing_if = "Option::is_none", default)]
133    pub cpu_limit: Option<CpuLimit>,
134    /// Unix signal to send for graceful shutdown (default: SIGTERM)
135    #[serde(skip_serializing_if = "Option::is_none", default)]
136    pub stop_signal: Option<StopConfig>,
137    /// Archive hook command invoked before retention prunes this daemon's logs.
138    #[serde(skip_serializing_if = "Option::is_none", default)]
139    pub archive_hook: Option<String>,
140    /// Log format for this daemon.
141    #[serde(skip_serializing_if = "Option::is_none", default)]
142    pub log_format: Option<String>,
143    /// Allocate a pseudo-terminal for the daemon process.
144    #[serde(skip_serializing_if = "Option::is_none", default)]
145    pub pty: Option<bool>,
146    /// True for daemons auto-registered from config by the cron watcher,
147    /// not yet started. Treated as "available" by list/status/stats.
148    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
149    pub config_registered: bool,
150}
151
152#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
153pub struct RunOptions {
154    pub id: DaemonId,
155    pub cmd: Vec<String>,
156    /// Original shell command string (from config `run`), passed verbatim to the shell.
157    /// Falls back to joining `cmd` when None (e.g. ad-hoc `pitchfork run -- cmd args`).
158    #[serde(skip_serializing_if = "Option::is_none", default)]
159    pub run: Option<String>,
160    pub force: bool,
161    pub shell_pid: Option<u32>,
162    pub dir: Dir,
163    pub autostop: bool,
164    pub cron_schedule: Option<String>,
165    pub cron_retrigger: Option<CronRetrigger>,
166    pub cron_immediate: Option<bool>,
167    pub retry: Retry,
168    pub retry_count: u32,
169    pub ready_delay: Option<u64>,
170    pub ready_output: Option<ReadyOutput>,
171    pub ready_http: Option<ReadyHttp>,
172    pub ready_port: Option<ReadyPort>,
173    pub ready_cmd: Option<ReadyCmd>,
174    pub port: Option<PortConfig>,
175    pub wait_ready: bool,
176    #[serde(skip_serializing_if = "Vec::is_empty", default)]
177    pub depends: Vec<DaemonId>,
178    #[serde(skip_serializing_if = "Option::is_none", default)]
179    pub env: Option<IndexMap<String, String>>,
180    #[serde(skip_serializing_if = "Vec::is_empty", default)]
181    pub watch: Vec<String>,
182    #[serde(default)]
183    pub watch_mode: WatchMode,
184    #[serde(skip_serializing_if = "Option::is_none", default)]
185    pub watch_base_dir: Option<PathBuf>,
186    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
187    ///
188    /// # Schema compatibility note
189    /// See `Daemon::mise` for downgrade implications when this field is `None`.
190    #[serde(skip_serializing_if = "Option::is_none", default)]
191    pub mise: Option<bool>,
192    /// Optional stable slug alias for this daemon.
193    #[serde(skip_serializing_if = "Option::is_none", default)]
194    pub slug: Option<String>,
195    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
196    #[serde(skip_serializing_if = "Option::is_none", default)]
197    pub proxy: Option<bool>,
198    /// Unix user to run this daemon as.
199    #[serde(skip_serializing_if = "Option::is_none", default)]
200    pub user: Option<String>,
201    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
202    #[serde(skip_serializing_if = "Option::is_none", default)]
203    pub memory_limit: Option<MemoryLimit>,
204    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub cpu_limit: Option<CpuLimit>,
207    /// Unix signal to send for graceful shutdown (default: SIGTERM)
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub stop_signal: Option<StopConfig>,
210    /// Archive hook command invoked before retention prunes this daemon's logs.
211    #[serde(skip_serializing_if = "Option::is_none", default)]
212    pub archive_hook: Option<String>,
213    /// Log format for this daemon: `json`, `logfmt`, `auto`, or `text`.
214    #[serde(skip_serializing_if = "Option::is_none", default)]
215    pub log_format: Option<String>,
216    /// Hook triggered when the daemon produces matching output
217    #[serde(skip_serializing_if = "Option::is_none", default)]
218    pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
219    /// Allocate a pseudo-terminal for the daemon process.
220    #[serde(skip_serializing_if = "Option::is_none", default)]
221    pub pty: Option<bool>,
222}
223
224impl Daemon {
225    /// Build RunOptions from persisted daemon state.
226    ///
227    /// Carries over all configuration fields from the daemon state.
228    /// Callers can override specific fields on the returned value.
229    pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
230        // Re-read on_output_hook from fresh config so restarts (retry, watch,
231        // cron) always pick up the current hook configuration.
232        // Use daemon.dir if available to handle daemons started via slugs
233        // whose project directory is not in the supervisor's cwd ancestry.
234        let on_output_hook = self
235            .dir
236            .as_deref()
237            .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
238            .or_else(|| crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces().ok())
239            .and_then(|pt| {
240                pt.daemons
241                    .get(&self.id)
242                    .and_then(|d| d.hooks.as_ref())
243                    .and_then(|h| h.on_output.clone())
244            });
245
246        RunOptions {
247            id: self.id.clone(),
248            cmd,
249            run: self.run.clone(),
250            force: false,
251            shell_pid: self.shell_pid,
252            dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
253            autostop: self.autostop,
254            cron_schedule: self.cron_schedule.clone(),
255            cron_retrigger: self.cron_retrigger,
256            cron_immediate: self.cron_immediate,
257            retry: self.retry,
258            retry_count: self.retry_count,
259            ready_delay: self.ready_delay,
260            ready_output: self.ready_output.clone(),
261            ready_http: self.ready_http.clone(),
262            ready_port: self.ready_port.clone(),
263            ready_cmd: self.ready_cmd.clone(),
264            port: self.port.clone(),
265            wait_ready: false,
266            depends: self.depends.clone(),
267            env: self.env.clone(),
268            watch: self.watch.clone(),
269            watch_mode: self.watch_mode,
270            watch_base_dir: self.watch_base_dir.clone(),
271            mise: self.mise,
272            slug: self.slug.clone(),
273            proxy: self.proxy,
274            user: self.user.clone(),
275            memory_limit: self.memory_limit,
276            cpu_limit: self.cpu_limit,
277            stop_signal: self.stop_signal,
278            archive_hook: self.archive_hook.clone(),
279            log_format: self.log_format.clone(),
280            on_output_hook,
281            pty: self.pty,
282        }
283    }
284}
285
286impl Display for Daemon {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        write!(f, "{}", self.id.qualified())
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_valid_daemon_ids() {
298        // Short IDs
299        assert!(is_valid_daemon_id("myapp"));
300        assert!(is_valid_daemon_id("my-app"));
301        assert!(is_valid_daemon_id("my_app"));
302        assert!(is_valid_daemon_id("my.app"));
303        assert!(is_valid_daemon_id("MyApp123"));
304
305        // Qualified IDs (namespace/short_id)
306        assert!(is_valid_daemon_id("project/api"));
307        assert!(is_valid_daemon_id("global/web"));
308        assert!(is_valid_daemon_id("my-project/my-app"));
309    }
310
311    #[test]
312    fn test_invalid_daemon_ids() {
313        // Empty
314        assert!(!is_valid_daemon_id(""));
315
316        // Multiple slashes (invalid qualified format)
317        assert!(!is_valid_daemon_id("a/b/c"));
318        assert!(!is_valid_daemon_id("../etc/passwd"));
319
320        // Invalid qualified format (empty parts)
321        assert!(!is_valid_daemon_id("/api"));
322        assert!(!is_valid_daemon_id("project/"));
323
324        // Backslashes
325        assert!(!is_valid_daemon_id("foo\\bar"));
326
327        // Parent directory reference
328        assert!(!is_valid_daemon_id(".."));
329        assert!(!is_valid_daemon_id("foo..bar"));
330
331        // Double dash (reserved for path encoding)
332        assert!(!is_valid_daemon_id("my--app"));
333        assert!(!is_valid_daemon_id("project--api"));
334        assert!(!is_valid_daemon_id("--app"));
335        assert!(!is_valid_daemon_id("app--"));
336
337        // Spaces
338        assert!(!is_valid_daemon_id("my app"));
339        assert!(!is_valid_daemon_id(" myapp"));
340        assert!(!is_valid_daemon_id("myapp "));
341
342        // Current directory
343        assert!(!is_valid_daemon_id("."));
344
345        // Control characters
346        assert!(!is_valid_daemon_id("my\x00app"));
347        assert!(!is_valid_daemon_id("my\napp"));
348        assert!(!is_valid_daemon_id("my\tapp"));
349
350        // Non-ASCII
351        assert!(!is_valid_daemon_id("myäpp"));
352        assert!(!is_valid_daemon_id("приложение"));
353
354        // Unsupported punctuation under DaemonId rules
355        assert!(!is_valid_daemon_id("app@host"));
356        assert!(!is_valid_daemon_id("app:8080"));
357    }
358}