fraisier_adapter_rc/
lib.rs1use 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
41const ADAPTER_NAME: &str = "rc";
43
44const PROGRAM_ENV: &str = "FRAISIER_SERVICE_BIN";
46
47pub 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 #[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 #[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 #[must_use]
94 pub fn with_transport(mut self, transport: Transport) -> Self {
95 self.transport = transport;
96 self
97 }
98
99 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 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
145fn 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 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 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}