duende_core/adapters/
systemd.rs

1//! Linux systemd adapter implementation.
2//!
3//! Provides daemon management via systemd transient units.
4
5use crate::adapter::{DaemonHandle, PlatformAdapter, PlatformError, PlatformResult, TracerHandle};
6use crate::daemon::Daemon;
7use crate::platform::Platform;
8use crate::types::{DaemonStatus, FailureReason, Signal};
9
10use async_trait::async_trait;
11use std::path::PathBuf;
12use tokio::process::Command;
13
14/// Linux systemd adapter.
15///
16/// Manages daemons as systemd transient units via `systemd-run` and `systemctl`.
17///
18/// # Requirements
19///
20/// - Linux with systemd (version 232+)
21/// - User must have permissions for `systemd-run --user` or root for system units
22///
23/// # Example
24///
25/// ```rust,ignore
26/// use duende_core::adapters::SystemdAdapter;
27/// use duende_core::PlatformAdapter;
28///
29/// let adapter = SystemdAdapter::user();
30/// let handle = adapter.spawn(my_daemon).await?;
31/// ```
32pub struct SystemdAdapter {
33    /// Directory for persistent unit files (not used for transient units)
34    unit_dir: PathBuf,
35    /// Use user session (--user) vs system session
36    user_mode: bool,
37}
38
39impl SystemdAdapter {
40    /// Creates a new systemd adapter with default settings (user mode).
41    ///
42    /// Alias for `user()` for API compatibility.
43    #[must_use]
44    pub fn new() -> Self {
45        Self::user()
46    }
47
48    /// Creates a new systemd adapter for system-level daemons.
49    ///
50    /// Requires root or appropriate polkit permissions.
51    #[must_use]
52    pub fn system() -> Self {
53        Self {
54            unit_dir: PathBuf::from("/etc/systemd/system"),
55            user_mode: false,
56        }
57    }
58
59    /// Creates a new systemd adapter for user-level daemons.
60    ///
61    /// Uses `systemd --user` session, no root required.
62    #[must_use]
63    pub fn user() -> Self {
64        Self {
65            unit_dir: dirs_next::config_dir()
66                .map(|p| p.join("systemd/user"))
67                .unwrap_or_else(|| PathBuf::from("~/.config/systemd/user")),
68            user_mode: true,
69        }
70    }
71
72    /// Creates adapter with custom unit directory.
73    #[must_use]
74    pub fn with_unit_dir(unit_dir: PathBuf, user_mode: bool) -> Self {
75        Self {
76            unit_dir,
77            user_mode,
78        }
79    }
80
81    /// Returns the unit directory path.
82    #[must_use]
83    pub fn unit_dir(&self) -> &PathBuf {
84        &self.unit_dir
85    }
86
87    /// Returns whether running in user mode.
88    #[must_use]
89    pub const fn is_user_mode(&self) -> bool {
90        self.user_mode
91    }
92
93    /// Generates a unit name from daemon name.
94    fn unit_name(daemon_name: &str) -> String {
95        format!("duende-{}.service", daemon_name.replace(' ', "-"))
96    }
97
98    /// Builds systemctl command with appropriate flags.
99    fn systemctl_cmd(&self) -> Command {
100        let mut cmd = Command::new("systemctl");
101        if self.user_mode {
102            cmd.arg("--user");
103        }
104        cmd
105    }
106
107    /// Builds systemd-run command for transient units.
108    fn systemd_run_cmd(&self) -> Command {
109        let mut cmd = Command::new("systemd-run");
110        if self.user_mode {
111            cmd.arg("--user");
112        }
113        cmd
114    }
115
116    /// Parses systemctl status output to DaemonStatus.
117    fn parse_status(output: &str, exit_code: i32) -> DaemonStatus {
118        // systemctl exit codes:
119        // 0 = running
120        // 1 = dead/failed (unit loaded but not active)
121        // 3 = not running (unit not loaded or inactive)
122        // 4 = no such unit
123
124        if exit_code == 0 {
125            // Check if actually running
126            if output.contains("Active: active (running)") {
127                return DaemonStatus::Running;
128            }
129            if output.contains("Active: activating") {
130                return DaemonStatus::Starting;
131            }
132        }
133
134        if output.contains("Active: deactivating") {
135            return DaemonStatus::Stopping;
136        }
137
138        if output.contains("Active: inactive") {
139            return DaemonStatus::Stopped;
140        }
141
142        if output.contains("Active: failed") {
143            return DaemonStatus::Failed(FailureReason::ExitCode(1));
144        }
145
146        // Unit doesn't exist or is stopped
147        DaemonStatus::Stopped
148    }
149
150    /// Maps Signal to systemctl kill signal name.
151    fn signal_name(sig: Signal) -> &'static str {
152        match sig {
153            Signal::Term => "SIGTERM",
154            Signal::Kill => "SIGKILL",
155            Signal::Int => "SIGINT",
156            Signal::Quit => "SIGQUIT",
157            Signal::Hup => "SIGHUP",
158            Signal::Usr1 => "SIGUSR1",
159            Signal::Usr2 => "SIGUSR2",
160            Signal::Stop => "SIGSTOP",
161            Signal::Cont => "SIGCONT",
162        }
163    }
164}
165
166impl Default for SystemdAdapter {
167    fn default() -> Self {
168        // Default to user mode for safety
169        Self::user()
170    }
171}
172
173#[async_trait]
174impl PlatformAdapter for SystemdAdapter {
175    fn platform(&self) -> Platform {
176        Platform::Linux
177    }
178
179    async fn spawn(&self, daemon: Box<dyn Daemon>) -> PlatformResult<DaemonHandle> {
180        let daemon_name = daemon.name().to_string();
181        let daemon_id = daemon.id();
182        let unit_name = Self::unit_name(&daemon_name);
183
184        // Build systemd-run command for transient unit
185        // Note: This creates a simple transient service. For full resource control,
186        // use DaemonConfig passed through a proper spawn_with_config method.
187        let mut cmd = self.systemd_run_cmd();
188        cmd.arg("--unit")
189            .arg(&unit_name)
190            .arg("--description")
191            .arg(format!("Duende daemon: {}", daemon_name))
192            .arg("--remain-after-exit")
193            .arg("--collect");
194
195        // Add restart policy
196        cmd.arg("--property=Restart=on-failure")
197            .arg("--property=RestartSec=5");
198
199        // For transient units, we need a command to run.
200        // In a real implementation, this would be configured via DaemonConfig.
201        // For now, use /bin/true as a placeholder for the test harness.
202        cmd.arg("--").arg("/bin/true");
203
204        // Execute systemd-run
205        let output = cmd.output().await.map_err(|e| {
206            PlatformError::spawn_failed(format!("Failed to execute systemd-run: {}", e))
207        })?;
208
209        if !output.status.success() {
210            let stderr = String::from_utf8_lossy(&output.stderr);
211            return Err(PlatformError::spawn_failed(format!(
212                "systemd-run failed: {}",
213                stderr
214            )));
215        }
216
217        // Note: We don't track PID for systemd units as systemd manages the process
218        Ok(DaemonHandle::systemd(daemon_id, unit_name))
219    }
220
221    async fn signal(&self, handle: &DaemonHandle, sig: Signal) -> PlatformResult<()> {
222        let unit_name = handle.systemd_unit().ok_or_else(|| {
223            PlatformError::spawn_failed("Invalid handle type for systemd adapter")
224        })?;
225
226        // Use systemctl kill to send signal
227        let mut cmd = self.systemctl_cmd();
228        cmd.arg("kill")
229            .arg("--signal")
230            .arg(Self::signal_name(sig))
231            .arg(unit_name);
232
233        let output = cmd.output().await.map_err(|e| {
234            PlatformError::spawn_failed(format!("Failed to execute systemctl kill: {}", e))
235        })?;
236
237        if !output.status.success() {
238            let stderr = String::from_utf8_lossy(&output.stderr);
239            return Err(PlatformError::spawn_failed(format!(
240                "systemctl kill failed: {}",
241                stderr
242            )));
243        }
244
245        Ok(())
246    }
247
248    async fn status(&self, handle: &DaemonHandle) -> PlatformResult<DaemonStatus> {
249        let unit_name = handle.systemd_unit().ok_or_else(|| {
250            PlatformError::spawn_failed("Invalid handle type for systemd adapter")
251        })?;
252
253        let mut cmd = self.systemctl_cmd();
254        cmd.arg("status").arg(unit_name);
255
256        let output = cmd.output().await.map_err(|e| {
257            PlatformError::spawn_failed(format!("Failed to execute systemctl status: {}", e))
258        })?;
259
260        let stdout = String::from_utf8_lossy(&output.stdout);
261        let exit_code = output.status.code().unwrap_or(1);
262
263        Ok(Self::parse_status(&stdout, exit_code))
264    }
265
266    async fn attach_tracer(&self, handle: &DaemonHandle) -> PlatformResult<TracerHandle> {
267        let unit_name = handle.systemd_unit().ok_or_else(|| {
268            PlatformError::spawn_failed("Invalid handle type for systemd adapter")
269        })?;
270
271        // Get the main PID for the unit
272        let pid = self.get_main_pid(unit_name).await.ok_or_else(|| {
273            PlatformError::spawn_failed("Cannot attach tracer: failed to get PID")
274        })?;
275
276        if pid == 0 {
277            return Err(PlatformError::spawn_failed(
278                "Cannot attach tracer: PID unknown",
279            ));
280        }
281
282        // Return a ptrace-based tracer handle
283        Ok(TracerHandle::ptrace(handle.id()))
284    }
285}
286
287impl SystemdAdapter {
288    /// Gets the main PID of a systemd unit.
289    async fn get_main_pid(&self, unit_name: &str) -> Option<u64> {
290        let mut cmd = self.systemctl_cmd();
291        cmd.arg("show")
292            .arg("--property=MainPID")
293            .arg("--value")
294            .arg(unit_name);
295
296        let output = cmd.output().await.ok()?;
297        if !output.status.success() {
298            return None;
299        }
300
301        let stdout = String::from_utf8_lossy(&output.stdout);
302        stdout.trim().parse().ok()
303    }
304
305    /// Stops a systemd unit.
306    pub async fn stop(&self, unit_name: &str) -> PlatformResult<()> {
307        let mut cmd = self.systemctl_cmd();
308        cmd.arg("stop").arg(unit_name);
309
310        let output = cmd.output().await.map_err(|e| {
311            PlatformError::spawn_failed(format!("Failed to execute systemctl stop: {}", e))
312        })?;
313
314        if !output.status.success() {
315            let stderr = String::from_utf8_lossy(&output.stderr);
316            return Err(PlatformError::spawn_failed(format!(
317                "systemctl stop failed: {}",
318                stderr
319            )));
320        }
321
322        Ok(())
323    }
324
325    /// Resets a failed systemd unit.
326    pub async fn reset_failed(&self, unit_name: &str) -> PlatformResult<()> {
327        let mut cmd = self.systemctl_cmd();
328        cmd.arg("reset-failed").arg(unit_name);
329
330        let _ = cmd.output().await; // Ignore errors
331        Ok(())
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_systemd_adapter_system() {
341        let adapter = SystemdAdapter::system();
342        assert!(!adapter.is_user_mode());
343        assert_eq!(adapter.unit_dir(), &PathBuf::from("/etc/systemd/system"));
344        assert_eq!(adapter.platform(), Platform::Linux);
345    }
346
347    #[test]
348    fn test_systemd_adapter_user() {
349        let adapter = SystemdAdapter::user();
350        assert!(adapter.is_user_mode());
351        assert_eq!(adapter.platform(), Platform::Linux);
352    }
353
354    #[test]
355    fn test_systemd_adapter_default() {
356        let adapter = SystemdAdapter::default();
357        assert!(adapter.is_user_mode()); // Default is user mode for safety
358    }
359
360    #[test]
361    fn test_unit_name_generation() {
362        assert_eq!(
363            SystemdAdapter::unit_name("my-daemon"),
364            "duende-my-daemon.service"
365        );
366        assert_eq!(
367            SystemdAdapter::unit_name("my daemon"),
368            "duende-my-daemon.service"
369        );
370    }
371
372    #[test]
373    fn test_signal_name() {
374        assert_eq!(SystemdAdapter::signal_name(Signal::Term), "SIGTERM");
375        assert_eq!(SystemdAdapter::signal_name(Signal::Kill), "SIGKILL");
376        assert_eq!(SystemdAdapter::signal_name(Signal::Hup), "SIGHUP");
377    }
378
379    #[test]
380    fn test_parse_status_running() {
381        let output = "● test.service - Test\n   Active: active (running) since...";
382        assert_eq!(
383            SystemdAdapter::parse_status(output, 0),
384            DaemonStatus::Running
385        );
386    }
387
388    #[test]
389    fn test_parse_status_stopped() {
390        let output = "● test.service - Test\n   Active: inactive (dead)";
391        assert_eq!(
392            SystemdAdapter::parse_status(output, 3),
393            DaemonStatus::Stopped
394        );
395    }
396
397    #[test]
398    fn test_parse_status_failed() {
399        let output = "● test.service - Test\n   Active: failed";
400        assert!(matches!(
401            SystemdAdapter::parse_status(output, 1),
402            DaemonStatus::Failed(_)
403        ));
404    }
405
406    #[test]
407    fn test_parse_status_starting() {
408        let output = "● test.service - Test\n   Active: activating (start)";
409        assert_eq!(
410            SystemdAdapter::parse_status(output, 0),
411            DaemonStatus::Starting
412        );
413    }
414
415    #[test]
416    fn test_with_unit_dir() {
417        let adapter = SystemdAdapter::with_unit_dir(PathBuf::from("/custom/path"), false);
418        assert_eq!(adapter.unit_dir(), &PathBuf::from("/custom/path"));
419        assert!(!adapter.is_user_mode());
420    }
421
422    // ==================== Extended Tests for Coverage ====================
423
424    #[test]
425    fn test_unit_name_special_characters() {
426        // Test various daemon names
427        assert_eq!(
428            SystemdAdapter::unit_name("test"),
429            "duende-test.service"
430        );
431        assert_eq!(
432            SystemdAdapter::unit_name("test-daemon"),
433            "duende-test-daemon.service"
434        );
435        assert_eq!(
436            SystemdAdapter::unit_name("test daemon name"),
437            "duende-test-daemon-name.service"
438        );
439        assert_eq!(
440            SystemdAdapter::unit_name(""),
441            "duende-.service"
442        );
443    }
444
445    #[test]
446    fn test_signal_name_all_signals() {
447        assert_eq!(SystemdAdapter::signal_name(Signal::Int), "SIGINT");
448        assert_eq!(SystemdAdapter::signal_name(Signal::Quit), "SIGQUIT");
449        assert_eq!(SystemdAdapter::signal_name(Signal::Usr1), "SIGUSR1");
450        assert_eq!(SystemdAdapter::signal_name(Signal::Usr2), "SIGUSR2");
451        assert_eq!(SystemdAdapter::signal_name(Signal::Stop), "SIGSTOP");
452        assert_eq!(SystemdAdapter::signal_name(Signal::Cont), "SIGCONT");
453    }
454
455    #[test]
456    fn test_parse_status_deactivating() {
457        let output = "● test.service - Test\n   Active: deactivating (stop-sigterm)";
458        assert_eq!(
459            SystemdAdapter::parse_status(output, 0),
460            DaemonStatus::Stopping
461        );
462    }
463
464    #[test]
465    fn test_parse_status_empty() {
466        assert_eq!(
467            SystemdAdapter::parse_status("", 4),
468            DaemonStatus::Stopped
469        );
470    }
471
472    #[test]
473    fn test_parse_status_unknown_output() {
474        let output = "Some random output without status";
475        assert_eq!(
476            SystemdAdapter::parse_status(output, 0),
477            DaemonStatus::Stopped
478        );
479    }
480
481    #[test]
482    fn test_parse_status_exit_codes() {
483        // exit code 0 without "active (running)" should be stopped
484        let output = "Some output";
485        assert_eq!(
486            SystemdAdapter::parse_status(output, 0),
487            DaemonStatus::Stopped
488        );
489
490        // exit code 1 should be stopped
491        assert_eq!(
492            SystemdAdapter::parse_status(output, 1),
493            DaemonStatus::Stopped
494        );
495
496        // exit code 3 should be stopped
497        assert_eq!(
498            SystemdAdapter::parse_status(output, 3),
499            DaemonStatus::Stopped
500        );
501
502        // exit code 4 should be stopped
503        assert_eq!(
504            SystemdAdapter::parse_status(output, 4),
505            DaemonStatus::Stopped
506        );
507    }
508
509    #[test]
510    fn test_parse_status_inactive_variations() {
511        let output1 = "Active: inactive (dead)";
512        assert_eq!(
513            SystemdAdapter::parse_status(output1, 3),
514            DaemonStatus::Stopped
515        );
516
517        let output2 = "  Active: inactive  ";
518        assert_eq!(
519            SystemdAdapter::parse_status(output2, 3),
520            DaemonStatus::Stopped
521        );
522    }
523
524    #[test]
525    fn test_parse_status_activating_variations() {
526        let output1 = "Active: activating (auto-restart)";
527        assert_eq!(
528            SystemdAdapter::parse_status(output1, 0),
529            DaemonStatus::Starting
530        );
531
532        let output2 = "Active: activating (start-pre)";
533        assert_eq!(
534            SystemdAdapter::parse_status(output2, 0),
535            DaemonStatus::Starting
536        );
537    }
538
539    #[test]
540    fn test_systemd_adapter_new_alias() {
541        let adapter = SystemdAdapter::new();
542        // new() is alias for user()
543        assert!(adapter.is_user_mode());
544    }
545
546    #[test]
547    fn test_systemd_adapter_clone_path() {
548        let adapter = SystemdAdapter::with_unit_dir(PathBuf::from("/test"), true);
549        let path = adapter.unit_dir().clone();
550        assert_eq!(path, PathBuf::from("/test"));
551    }
552
553    #[test]
554    fn test_parse_status_failed_variations() {
555        // Test failed status in different contexts
556        let output1 = "Active: failed (Result: exit-code)";
557        assert!(matches!(
558            SystemdAdapter::parse_status(output1, 1),
559            DaemonStatus::Failed(_)
560        ));
561
562        let output2 = "Active: failed (Result: timeout)";
563        assert!(matches!(
564            SystemdAdapter::parse_status(output2, 1),
565            DaemonStatus::Failed(_)
566        ));
567    }
568
569    #[test]
570    fn test_systemctl_cmd_user_mode() {
571        let adapter = SystemdAdapter::user();
572        let cmd = adapter.systemctl_cmd();
573        // Just verify it's constructed (we can't easily inspect the args)
574        let _ = cmd;
575    }
576
577    #[test]
578    fn test_systemctl_cmd_system_mode() {
579        let adapter = SystemdAdapter::system();
580        let cmd = adapter.systemctl_cmd();
581        let _ = cmd;
582    }
583
584    #[test]
585    fn test_systemd_run_cmd_user_mode() {
586        let adapter = SystemdAdapter::user();
587        let cmd = adapter.systemd_run_cmd();
588        let _ = cmd;
589    }
590
591    #[test]
592    fn test_systemd_run_cmd_system_mode() {
593        let adapter = SystemdAdapter::system();
594        let cmd = adapter.systemd_run_cmd();
595        let _ = cmd;
596    }
597}