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