Skip to main content

fraisier_adapter_rc/
lib.rs

1//! # fraisier-adapter-rc
2//!
3//! The [`RcService`] adapter: a [`ServiceAdapter`] that drives a FreeBSD rc.d
4//! service through the `service(8)` CLI (PRD §6.3 — shell out in v1.0).
5//!
6//! ## Configuration
7//!
8//! Read per call from [`AdapterCtx::settings`] (the `[service]` table):
9//!
10//! ```toml
11//! [service]
12//! adapter = "rc"
13//! name = "fraiseql"     # the rc.d service name (the `service <name> …` argument)
14//! ```
15//!
16//! ## `service(8)` argument order
17//!
18//! Unlike `systemctl <verb> <unit>`, FreeBSD's `service` takes the name *before*
19//! the command: `service <name> restart`, `service <name> status`. The status
20//! sub-command reports `"<name> is running as pid N."` (exit 0) or
21//! `"<name> is not running."` (exit 1); [`RcService::status`] reads that text and
22//! falls back to the exit code, so a stopped service is a normal `running: false`
23//! result rather than an error.
24//!
25//! ## Locality
26//!
27//! By default `service` runs on the **local** host; build with
28//! [`RcService::with_transport`] and a [`Transport::Ssh`] to run it on a remote
29//! host (the multi-host rollout does this per host). The adapter never assumes
30//! privilege — escalation (sudo) is the operator's concern.
31
32use std::ffi::OsString;
33
34use async_trait::async_trait;
35use fraisier_adapter_support::{error, Captured, Transport};
36use fraisier_core::adapter_axes::{
37    AdapterCtx, AdapterError, AdapterErrorKind, HostId, ServiceAdapter, ServiceStatus,
38};
39use serde_json::Value;
40
41/// The adapter's identity name.
42const ADAPTER_NAME: &str = "rc";
43
44/// The env var that overrides which `service` binary the adapter spawns.
45const PROGRAM_ENV: &str = "FRAISIER_SERVICE_BIN";
46
47/// A [`ServiceAdapter`] backed by FreeBSD's `service(8)`.
48///
49/// # Example
50/// ```
51/// use fraisier_adapter_rc::RcService;
52///
53/// let adapter = RcService::new();
54/// let pinned = RcService::with_program("/usr/sbin/service");
55/// let _ = (adapter, pinned);
56/// ```
57pub struct RcService {
58    program: OsString,
59    transport: Transport,
60}
61
62impl Default for RcService {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl RcService {
69    /// Create an adapter that spawns `service` (honouring the
70    /// `FRAISIER_SERVICE_BIN` override) on the **local** host.
71    #[must_use]
72    pub fn new() -> Self {
73        let program = std::env::var_os(PROGRAM_ENV)
74            .filter(|value| !value.is_empty())
75            .unwrap_or_else(|| OsString::from("service"));
76        Self {
77            program,
78            transport: Transport::Local,
79        }
80    }
81
82    /// Create an adapter that spawns the binary at `program`.
83    #[must_use]
84    pub fn with_program(program: impl Into<OsString>) -> Self {
85        Self {
86            program: program.into(),
87            transport: Transport::Local,
88        }
89    }
90
91    /// Run `service` over `transport` instead of locally (the multi-host path
92    /// passes a [`Transport::Ssh`] to manage the service on each remote host).
93    #[must_use]
94    pub fn with_transport(mut self, transport: Transport) -> Self {
95        self.transport = transport;
96        self
97    }
98
99    /// Build the argv for a `service` `verb` against the configured service:
100    /// `<name> <verb>` (the name precedes the command, unlike `systemctl`).
101    fn args_for(
102        ctx: &AdapterCtx,
103        verb: &str,
104        operation: &str,
105    ) -> Result<Vec<OsString>, AdapterError> {
106        let name = ctx
107            .settings
108            .get("name")
109            .and_then(Value::as_str)
110            .filter(|name| !name.is_empty())
111            .ok_or_else(|| {
112                error(
113                    AdapterErrorKind::InvalidConfig,
114                    ADAPTER_NAME,
115                    operation,
116                    "no 'name' configured in [service] settings".to_owned(),
117                    None,
118                )
119            })?;
120        Ok(vec![OsString::from(name), OsString::from(verb)])
121    }
122
123    /// Run a `service` `verb`, returning the captured output.
124    async fn service(
125        &self,
126        ctx: &AdapterCtx,
127        verb: &str,
128        operation: &str,
129    ) -> Result<Captured, AdapterError> {
130        let args = Self::args_for(ctx, verb, operation)?;
131        self.transport
132            .run(
133                ctx,
134                &self.program,
135                &args,
136                &[],
137                None,
138                ADAPTER_NAME,
139                operation,
140            )
141            .await
142    }
143}
144
145/// Interpret a `service <name> status` result.
146///
147/// rc.d status text is the source of truth (`"is not running"` is checked before
148/// `"is running"` so it can't be masked); when the script prints neither phrase,
149/// the exit code decides.
150fn parse_status(captured: &Captured) -> ServiceStatus {
151    let text = captured.stdout.trim();
152    let running = if text.contains("is not running") {
153        false
154    } else if text.contains("is running") {
155        true
156    } else {
157        captured.succeeded()
158    };
159    ServiceStatus {
160        running,
161        detail: text.lines().next().map(str::to_owned),
162    }
163}
164
165#[async_trait]
166impl ServiceAdapter for RcService {
167    async fn restart(&self, ctx: &AdapterCtx, _host: &HostId) -> Result<(), AdapterError> {
168        let captured = self.service(ctx, "restart", "restart").await?;
169        if captured.succeeded() {
170            return Ok(());
171        }
172        let code = captured
173            .code
174            .map_or_else(|| "signal".to_owned(), |code| code.to_string());
175        Err(error(
176            AdapterErrorKind::Execution,
177            ADAPTER_NAME,
178            "restart",
179            format!("`service <name> restart` exited with {code}"),
180            captured.stderr_opt(),
181        ))
182    }
183
184    async fn status(
185        &self,
186        ctx: &AdapterCtx,
187        _host: &HostId,
188    ) -> Result<ServiceStatus, AdapterError> {
189        // A stopped service exits non-zero; that is informational here, not a
190        // spawn error, so only a failure to spawn `service` propagates.
191        let captured = self.service(ctx, "status", "status").await?;
192        Ok(parse_status(&captured))
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::{parse_status, RcService};
199    use fraisier_adapter_support::Captured;
200    use fraisier_core::adapter_axes::AdapterCtx;
201    use serde_json::json;
202
203    fn captured(code: i32, stdout: &str) -> Captured {
204        Captured {
205            code: Some(code),
206            stdout: stdout.to_owned(),
207            stderr: String::new(),
208        }
209    }
210
211    #[test]
212    fn args_require_a_name() {
213        let ctx = AdapterCtx::new("checkout", "production");
214        let err =
215            RcService::args_for(&ctx, "restart", "restart").expect_err("missing name must fail");
216        assert_eq!(err.adapter.as_deref(), Some("rc"));
217    }
218
219    #[test]
220    fn args_put_the_name_before_the_verb() {
221        let mut ctx = AdapterCtx::new("checkout", "production");
222        ctx.settings.insert("name".to_owned(), json!("fraiseql"));
223        let args = RcService::args_for(&ctx, "status", "status").expect("args");
224        assert_eq!(args, vec!["fraiseql", "status"]);
225    }
226
227    #[test]
228    fn status_reads_the_running_phrase() {
229        let status = parse_status(&captured(0, "fraiseql is running as pid 4321."));
230        assert!(status.running);
231        assert_eq!(
232            status.detail.as_deref(),
233            Some("fraiseql is running as pid 4321.")
234        );
235    }
236
237    #[test]
238    fn status_reads_the_not_running_phrase_despite_exit_zero() {
239        // The phrase wins over a misleading exit code.
240        let status = parse_status(&captured(0, "fraiseql is not running."));
241        assert!(!status.running);
242    }
243
244    #[test]
245    fn status_falls_back_to_the_exit_code_without_a_phrase() {
246        assert!(parse_status(&captured(0, "")).running);
247        assert!(!parse_status(&captured(1, "")).running);
248    }
249}