Skip to main content

pitchfork_cli/
daemon.rs

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
12/// Validates a daemon ID to ensure it's safe for use in file paths and IPC.
13///
14/// A valid daemon ID:
15/// - Is not empty
16/// - Does not contain backslashes (`\`)
17/// - Does not contain parent directory references (`..`)
18/// - Does not contain spaces
19/// - Does not contain `--` (reserved for path encoding of `/`)
20/// - Is not `.` (current directory)
21/// - Contains only printable ASCII characters
22/// - If qualified (contains `/`), has exactly one `/` separating namespace and short ID
23///
24/// Format: `[namespace/]short_id`
25/// - Qualified: `project/api`, `global/web`
26/// - Short: `api`, `web`
27///
28/// This validation prevents path traversal attacks when daemon IDs are used
29/// to construct log file paths or other filesystem operations.
30pub 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    /// High-resolution kernel start token recorded at spawn. Together with
44    /// `pid` this identifies the process across a supervisor crash: a recycled
45    /// PID has a different token, so orphan cleanup can tell a genuine orphan
46    /// from an unrelated process.
47    #[serde(skip_serializing_if = "Option::is_none", default)]
48    pub start_time: Option<u64>,
49    /// System boot time (seconds since epoch) recorded at spawn. Lets orphan
50    /// reconciliation tell a daemon that died under a crashed supervisor
51    /// during this boot from one whose process died with the machine, which
52    /// need different terminal states. Unlike `start_time` this is a
53    /// wall-clock value comparable across processes and platforms.
54    #[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    /// Original shell command string, persisted for retry/watch restarts.
62    #[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    /// Port configuration (expected ports and auto-bump settings)
96    #[serde(skip_serializing_if = "Option::is_none", default)]
97    pub port: Option<PortConfig>,
98    /// Resolved ports actually used after auto-bump (may differ from expected)
99    #[serde(skip_serializing_if = "Vec::is_empty", default)]
100    pub resolved_port: Vec<u16>,
101    /// The first port the process is actually listening on (detected at runtime via listeners crate).
102    /// This is the source of truth for the reverse proxy. Cleared when the daemon stops.
103    #[serde(skip_serializing_if = "Option::is_none", default)]
104    pub active_port: Option<u16>,
105    /// Optional stable slug alias for this daemon (used in proxy URLs and CLI commands).
106    #[serde(skip_serializing_if = "Option::is_none", default)]
107    pub slug: Option<String>,
108    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
109    #[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    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
122    ///
123    /// # Schema compatibility note
124    /// This field changed from `bool` to `Option<bool>` with `skip_serializing_if = "Option::is_none"`.
125    /// - **Upgrade (old → new):** safe — old files contain `mise = true/false`, which deserialize
126    ///   correctly as `Some(true)` / `Some(false)`.
127    /// - **Downgrade (new → old):** if `mise` is `None` (inherit global), the key is omitted from
128    ///   the state file. An old binary reads the missing key as `false`, ignoring `general.mise = true`.
129    ///   Any daemon that relied on the global setting would silently stop using mise after a downgrade.
130    #[serde(skip_serializing_if = "Option::is_none", default)]
131    pub mise: Option<bool>,
132    /// Unix user to run this daemon as.
133    #[serde(skip_serializing_if = "Option::is_none", default)]
134    pub user: Option<String>,
135    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
136    #[serde(skip_serializing_if = "Option::is_none", default)]
137    pub memory_limit: Option<MemoryLimit>,
138    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
139    #[serde(skip_serializing_if = "Option::is_none", default)]
140    pub cpu_limit: Option<CpuLimit>,
141    /// Unix signal to send for graceful shutdown (default: SIGTERM)
142    #[serde(skip_serializing_if = "Option::is_none", default)]
143    pub stop_signal: Option<StopConfig>,
144    /// Archive hook command invoked before retention prunes this daemon's logs.
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub archive_hook: Option<String>,
147    /// Log format for this daemon.
148    #[serde(skip_serializing_if = "Option::is_none", default)]
149    pub log_format: Option<String>,
150    /// Allocate a pseudo-terminal for the daemon process.
151    #[serde(skip_serializing_if = "Option::is_none", default)]
152    pub pty: Option<bool>,
153    /// True for daemons auto-registered from config by the cron watcher,
154    /// not yet started. Treated as "available" by list/status/stats.
155    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
156    pub config_registered: bool,
157    /// Run-to-completion task rather than a long-running service. Readiness is
158    /// a zero exit code, and the terminal state is `completed` instead of
159    /// `stopped`. See `DaemonStatus::Completed`.
160    ///
161    /// Appended rather than grouped with `status`: IPC encodes this struct
162    /// positionally, so a field inserted in the middle shifts every field
163    /// after it for a peer that does not have it.
164    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
165    pub oneshot: bool,
166}
167
168#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
169pub struct RunOptions {
170    pub id: DaemonId,
171    pub cmd: Vec<String>,
172    /// Original shell command string (from config `run`), passed verbatim to the shell.
173    /// Falls back to joining `cmd` when None (e.g. ad-hoc `pitchfork run -- cmd args`).
174    #[serde(skip_serializing_if = "Option::is_none", default)]
175    pub run: Option<String>,
176    pub force: bool,
177    pub shell_pid: Option<u32>,
178    pub dir: Dir,
179    pub autostop: bool,
180    pub cron_schedule: Option<String>,
181    pub cron_retrigger: Option<CronRetrigger>,
182    pub cron_immediate: Option<bool>,
183    pub retry: Retry,
184    pub retry_count: u32,
185    pub ready_delay: Option<u64>,
186    pub ready_output: Option<ReadyOutput>,
187    pub ready_http: Option<ReadyHttp>,
188    pub ready_port: Option<ReadyPort>,
189    pub ready_cmd: Option<ReadyCmd>,
190    pub health_cmd: Option<HealthCmd>,
191    pub health_http: Option<HealthHttp>,
192    pub health_port: Option<HealthPort>,
193    pub port: Option<PortConfig>,
194    pub wait_ready: bool,
195    #[serde(skip_serializing_if = "Vec::is_empty", default)]
196    pub depends: Vec<DaemonId>,
197    #[serde(skip_serializing_if = "Option::is_none", default)]
198    pub env: Option<IndexMap<String, String>>,
199    #[serde(skip_serializing_if = "Vec::is_empty", default)]
200    pub watch: Vec<String>,
201    #[serde(default)]
202    pub watch_mode: WatchMode,
203    #[serde(skip_serializing_if = "Option::is_none", default)]
204    pub watch_base_dir: Option<PathBuf>,
205    /// Whether to use mise for this daemon (None = inherit global general.mise setting).
206    ///
207    /// # Schema compatibility note
208    /// See `Daemon::mise` for downgrade implications when this field is `None`.
209    #[serde(skip_serializing_if = "Option::is_none", default)]
210    pub mise: Option<bool>,
211    /// Optional stable slug alias for this daemon.
212    #[serde(skip_serializing_if = "Option::is_none", default)]
213    pub slug: Option<String>,
214    /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
215    #[serde(skip_serializing_if = "Option::is_none", default)]
216    pub proxy: Option<bool>,
217    /// Unix user to run this daemon as.
218    #[serde(skip_serializing_if = "Option::is_none", default)]
219    pub user: Option<String>,
220    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
221    #[serde(skip_serializing_if = "Option::is_none", default)]
222    pub memory_limit: Option<MemoryLimit>,
223    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
224    #[serde(skip_serializing_if = "Option::is_none", default)]
225    pub cpu_limit: Option<CpuLimit>,
226    /// Unix signal to send for graceful shutdown (default: SIGTERM)
227    #[serde(skip_serializing_if = "Option::is_none", default)]
228    pub stop_signal: Option<StopConfig>,
229    /// Archive hook command invoked before retention prunes this daemon's logs.
230    #[serde(skip_serializing_if = "Option::is_none", default)]
231    pub archive_hook: Option<String>,
232    /// Log format for this daemon: `json`, `logfmt`, `auto`, or `text`.
233    #[serde(skip_serializing_if = "Option::is_none", default)]
234    pub log_format: Option<String>,
235    /// Hook triggered when the daemon produces matching output
236    #[serde(skip_serializing_if = "Option::is_none", default)]
237    pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
238    /// Allocate a pseudo-terminal for the daemon process.
239    #[serde(skip_serializing_if = "Option::is_none", default)]
240    pub pty: Option<bool>,
241    /// Run-to-completion task rather than a long-running service.
242    ///
243    /// Appended rather than grouped with `autostop`: IPC encodes this struct
244    /// positionally, so a field inserted in the middle shifts every field
245    /// after it for a CLI or supervisor that does not have it, and a version
246    /// mismatch is only warned about, not refused.
247    #[serde(default)]
248    pub oneshot: bool,
249    /// How long to wait for a oneshot to finish, already resolved from the
250    /// project's `supervisor.oneshot_timeout`.
251    ///
252    /// Resolved by the client and carried on the request because the
253    /// supervisor is long-lived and may have started in another directory, so
254    /// its own `settings()` would not see the project's value. `None` leaves
255    /// the supervisor to fall back to whatever it can resolve.
256    #[serde(skip_serializing_if = "Option::is_none", default)]
257    pub oneshot_wait: Option<OneshotWait>,
258    /// This start came from entering a directory rather than from a person
259    /// asking for it, so a completed `oneshot` is left alone. Decided by the
260    /// supervisor because only it holds authoritative state: the state file
261    /// lags it by up to the flush interval, which is exactly the window a
262    /// second directory entry lands in.
263    #[serde(default)]
264    pub on_directory_enter: bool,
265}
266
267impl Daemon {
268    /// Build RunOptions from persisted daemon state.
269    ///
270    /// Carries over all configuration fields from the daemon state.
271    /// Callers can override specific fields on the returned value.
272    pub fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
273        // Re-read on_output_hook from fresh config so restarts (retry, watch,
274        // cron) always pick up the current hook configuration.
275        // Use daemon.dir if available to handle daemons started via slugs
276        // whose project directory is not in the supervisor's cwd ancestry.
277        let on_output_hook = self
278            .dir
279            .as_deref()
280            .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
281            .or_else(|| crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces().ok())
282            .and_then(|pt| {
283                pt.daemons
284                    .get(&self.id)
285                    .and_then(|d| d.hooks.as_ref())
286                    .and_then(|h| h.on_output.clone())
287            });
288
289        RunOptions {
290            id: self.id.clone(),
291            cmd,
292            run: self.run.clone(),
293            force: false,
294            shell_pid: self.shell_pid,
295            dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
296            autostop: self.autostop,
297            oneshot: self.oneshot,
298            // Re-resolved by the client on the paths that have a project to
299            // resolve it from; a supervisor-internal restart keeps None and
300            // falls back.
301            oneshot_wait: None,
302            on_directory_enter: false,
303            cron_schedule: self.cron_schedule.clone(),
304            cron_retrigger: self.cron_retrigger,
305            cron_immediate: self.cron_immediate,
306            retry: self.retry,
307            retry_count: self.retry_count,
308            ready_delay: self.ready_delay,
309            ready_output: self.ready_output.clone(),
310            ready_http: self.ready_http.clone(),
311            ready_port: self.ready_port.clone(),
312            ready_cmd: self.ready_cmd.clone(),
313            health_cmd: self.health_cmd.clone(),
314            health_http: self.health_http.clone(),
315            health_port: self.health_port.clone(),
316            port: self.port.clone(),
317            wait_ready: false,
318            depends: self.depends.clone(),
319            env: self.env.clone(),
320            watch: self.watch.clone(),
321            watch_mode: self.watch_mode,
322            watch_base_dir: self.watch_base_dir.clone(),
323            mise: self.mise,
324            slug: self.slug.clone(),
325            proxy: self.proxy,
326            user: self.user.clone(),
327            memory_limit: self.memory_limit,
328            cpu_limit: self.cpu_limit,
329            stop_signal: self.stop_signal,
330            archive_hook: self.archive_hook.clone(),
331            log_format: self.log_format.clone(),
332            on_output_hook,
333            pty: self.pty,
334        }
335    }
336}
337
338impl Display for Daemon {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        write!(f, "{}", self.id.qualified())
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_valid_daemon_ids() {
350        // Short IDs
351        assert!(is_valid_daemon_id("myapp"));
352        assert!(is_valid_daemon_id("my-app"));
353        assert!(is_valid_daemon_id("my_app"));
354        assert!(is_valid_daemon_id("my.app"));
355        assert!(is_valid_daemon_id("MyApp123"));
356
357        // Qualified IDs (namespace/short_id)
358        assert!(is_valid_daemon_id("project/api"));
359        assert!(is_valid_daemon_id("global/web"));
360        assert!(is_valid_daemon_id("my-project/my-app"));
361    }
362
363    #[test]
364    fn test_invalid_daemon_ids() {
365        // Empty
366        assert!(!is_valid_daemon_id(""));
367
368        // Multiple slashes (invalid qualified format)
369        assert!(!is_valid_daemon_id("a/b/c"));
370        assert!(!is_valid_daemon_id("../etc/passwd"));
371
372        // Invalid qualified format (empty parts)
373        assert!(!is_valid_daemon_id("/api"));
374        assert!(!is_valid_daemon_id("project/"));
375
376        // Backslashes
377        assert!(!is_valid_daemon_id("foo\\bar"));
378
379        // Parent directory reference
380        assert!(!is_valid_daemon_id(".."));
381        assert!(!is_valid_daemon_id("foo..bar"));
382
383        // Double dash (reserved for path encoding)
384        assert!(!is_valid_daemon_id("my--app"));
385        assert!(!is_valid_daemon_id("project--api"));
386        assert!(!is_valid_daemon_id("--app"));
387        assert!(!is_valid_daemon_id("app--"));
388
389        // Spaces
390        assert!(!is_valid_daemon_id("my app"));
391        assert!(!is_valid_daemon_id(" myapp"));
392        assert!(!is_valid_daemon_id("myapp "));
393
394        // Current directory
395        assert!(!is_valid_daemon_id("."));
396
397        // Control characters
398        assert!(!is_valid_daemon_id("my\x00app"));
399        assert!(!is_valid_daemon_id("my\napp"));
400        assert!(!is_valid_daemon_id("my\tapp"));
401
402        // Non-ASCII
403        assert!(!is_valid_daemon_id("myäpp"));
404        assert!(!is_valid_daemon_id("приложение"));
405
406        // Unsupported punctuation under DaemonId rules
407        assert!(!is_valid_daemon_id("app@host"));
408        assert!(!is_valid_daemon_id("app:8080"));
409    }
410}