nixfleet_agent/activation/
darwin.rs

1//! Darwin (nix-darwin) activation primitives. `setsid`-detached children
2//! survive launchd's process-group SIGTERM during plist reload; `nohup`
3//! doesn't work in launchd's no-controlling-tty context.
4
5use std::process::Stdio;
6
7use super::types::ActivationTarget;
8use anyhow::Result;
9
10use super::{ActivationBackend, ActivationOutcome, RollbackOutcome};
11
12#[derive(Clone, Copy, Debug, Default)]
13pub struct DarwinBackend;
14
15impl ActivationBackend for DarwinBackend {
16    async fn is_switch_in_progress(&self) -> bool {
17        false
18    }
19    async fn read_unit_exit_code(&self, _unit_name: &str) -> Option<i32> {
20        None
21    }
22    async fn fire_switch(
23        &self,
24        target: &ActivationTarget,
25        store_path: &str,
26    ) -> Result<Option<ActivationOutcome>> {
27        fire_switch(target, store_path).await
28    }
29    async fn fire_rollback(&self, target_basename: &str) -> Result<Option<RollbackOutcome>> {
30        fire_rollback(target_basename).await
31    }
32}
33
34async fn fire_switch(
35    target: &ActivationTarget,
36    store_path: &str,
37) -> Result<Option<ActivationOutcome>> {
38    use std::os::unix::process::CommandExt;
39
40    tracing::info!(
41        target_closure = %target.closure_hash,
42        "agent: firing darwin activation (setsid-detached activate-user + activate)",
43    );
44
45    // GOTCHA: activate-user is legacy - modern closures often omit it; spawn errors non-fatal.
46    let activate_user = format!("{store_path}/activate-user");
47    if std::path::Path::new(&activate_user).exists() {
48        let mut cmd = std::process::Command::new(&activate_user);
49        cmd.stdin(Stdio::null());
50        attach_activate_log_to(&mut cmd, ACTIVATE_LOG);
51        // SAFETY: setsid is async-signal-safe; no alloc/lock in the closure.
52        unsafe {
53            cmd.pre_exec(|| {
54                if libc::setsid() == -1 {
55                    return Err(std::io::Error::last_os_error());
56                }
57                Ok(())
58            });
59        }
60        match cmd.spawn() {
61            Ok(_child) => {
62                tracing::debug!(
63                    target_closure = %target.closure_hash,
64                    "agent: darwin activate-user fired (detached)",
65                );
66            }
67            Err(err) => {
68                tracing::warn!(
69                    target_closure = %target.closure_hash,
70                    error = %err,
71                    "agent: darwin activate-user spawn failed (non-fatal); continuing to system activate",
72                );
73            }
74        }
75    } else {
76        tracing::debug!(
77            target_closure = %target.closure_hash,
78            "agent: darwin activate-user absent; skipping (modern closure shape)",
79        );
80    }
81
82    // LOADBEARING: setsid detach survives launchd plist reload; nohup doesn't work without controlling tty.
83    let activate = format!("{store_path}/activate");
84    let mut cmd = std::process::Command::new(&activate);
85    cmd.stdin(Stdio::null());
86    attach_activate_log_to(&mut cmd, ACTIVATE_LOG);
87    unsafe {
88        cmd.pre_exec(|| {
89            if libc::setsid() == -1 {
90                return Err(std::io::Error::last_os_error());
91            }
92            Ok(())
93        });
94    }
95    match cmd.spawn() {
96        Ok(_child) => {
97            tracing::info!(
98                target_closure = %target.closure_hash,
99                "agent: darwin activate fired (setsid-detached); polling current-system",
100            );
101            Ok(None)
102        }
103        Err(err) => {
104            tracing::error!(
105                target_closure = %target.closure_hash,
106                error = %err,
107                "agent: darwin activate spawn failed",
108            );
109            Ok(Some(ActivationOutcome::SwitchFailed {
110                phase: "darwin-activate-spawn".to_string(),
111                exit_code: None,
112            }))
113        }
114    }
115}
116
117async fn fire_rollback(target_basename: &str) -> Result<Option<RollbackOutcome>> {
118    use std::os::unix::process::CommandExt;
119
120    let store_path = format!("/nix/store/{target_basename}");
121    let activate = format!("{store_path}/activate");
122    if !std::path::Path::new(&activate).exists() {
123        tracing::error!(
124            activate = %activate,
125            "agent: darwin rollback target has no activate script",
126        );
127        return Ok(Some(RollbackOutcome::Failed {
128            phase: "darwin-activate-missing".to_string(),
129            exit_code: None,
130        }));
131    }
132
133    tracing::info!(
134        target = %target_basename,
135        "agent: firing darwin rollback (setsid-detached activate)",
136    );
137    let mut cmd = std::process::Command::new(&activate);
138    cmd.stdin(Stdio::null());
139    attach_activate_log_to(&mut cmd, ACTIVATE_LOG);
140    unsafe {
141        cmd.pre_exec(|| {
142            if libc::setsid() == -1 {
143                return Err(std::io::Error::last_os_error());
144            }
145            Ok(())
146        });
147    }
148    match cmd.spawn() {
149        Ok(_child) => Ok(None),
150        Err(err) => {
151            tracing::error!(
152                target = %target_basename,
153                error = %err,
154                "agent: darwin rollback activate spawn failed",
155            );
156            Ok(Some(RollbackOutcome::Failed {
157                phase: "darwin-activate-spawn".to_string(),
158                exit_code: None,
159            }))
160        }
161    }
162}
163
164const ACTIVATE_LOG: &str = "/var/log/nixfleet-activate.log";
165
166/// Falls back to inherit on IO error; launchd's StandardOutPath catches it.
167fn attach_activate_log_to(cmd: &mut std::process::Command, path: &str) {
168    match std::fs::OpenOptions::new()
169        .create(true)
170        .append(true)
171        .open(path)
172    {
173        Ok(out) => {
174            let err = match out.try_clone() {
175                Ok(c) => c,
176                Err(e) => {
177                    tracing::warn!(
178                        path = path,
179                        error = %e,
180                        "could not clone activate log handle; using inherit",
181                    );
182                    cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
183                    return;
184                }
185            };
186            cmd.stdout(out).stderr(err);
187        }
188        Err(e) => {
189            tracing::warn!(
190                path = path,
191                error = %e,
192                "could not open activate log; using inherit",
193            );
194            cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[tokio::test]
204    async fn darwin_backend_is_switch_in_progress_returns_false() {
205        assert!(!DarwinBackend.is_switch_in_progress().await);
206    }
207
208    #[tokio::test]
209    async fn darwin_backend_read_unit_exit_code_returns_none() {
210        assert_eq!(DarwinBackend.read_unit_exit_code("anything").await, None);
211    }
212
213    #[test]
214    fn attach_activate_log_falls_back_to_inherit_when_path_unwritable() {
215        let dir = tempfile::tempdir().expect("tempdir");
216        let unwritable = dir.path().join("does-not-exist").join("nope.log");
217        let mut cmd = std::process::Command::new("true");
218        attach_activate_log_to(&mut cmd, unwritable.to_str().unwrap());
219    }
220
221    #[test]
222    fn attach_activate_log_succeeds_when_path_writable() {
223        let dir = tempfile::tempdir().expect("tempdir");
224        let path = dir.path().join("activate.log");
225        let mut cmd = std::process::Command::new("true");
226        attach_activate_log_to(&mut cmd, path.to_str().unwrap());
227        assert!(path.exists(), "log file should be created");
228    }
229
230    #[test]
231    fn darwin_backend_default_is_unit_struct() {
232        let _b: DarwinBackend = DarwinBackend;
233        #[allow(clippy::default_constructed_unit_structs)]
234        let _: DarwinBackend = DarwinBackend::default();
235    }
236}