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