bootc_internal_utils/
command.rs1use std::{
4 fmt::Write,
5 io::{Read, Seek},
6 os::unix::process::CommandExt,
7 process::Command,
8};
9
10use anyhow::{Context, Result};
11
12pub trait CommandRunExt {
14 fn log_debug(&mut self) -> &mut Self;
16
17 fn run_inherited(&mut self) -> Result<()>;
27
28 fn run_capture_stderr(&mut self) -> Result<()>;
39
40 fn run_inherited_with_cmd_context(&mut self) -> Result<()>;
51
52 fn lifecycle_bind(&mut self) -> &mut Self;
54
55 fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>>;
58
59 fn run_get_string(&mut self) -> Result<String>;
62
63 fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T>;
66
67 fn to_string_pretty(&self) -> String;
69}
70
71pub trait ExitStatusExt {
73 fn check_status(&mut self) -> Result<()>;
78
79 fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()>;
84}
85
86fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
91 const MAX_STDERR_BYTES: u16 = 1024;
94 let size = f
95 .metadata()
96 .map_err(|e| {
97 tracing::warn!("failed to fstat: {e}");
98 })
99 .map(|m| m.len().try_into().unwrap_or(u16::MAX))
100 .unwrap_or(0);
101 let size = size.min(MAX_STDERR_BYTES);
102 let seek_offset = -(size as i32);
103 let mut stderr_buf = Vec::with_capacity(size.into());
104 let r = match f
106 .seek(std::io::SeekFrom::End(seek_offset.into()))
107 .and_then(|_| f.read_to_end(&mut stderr_buf))
108 {
109 Ok(_) => String::from_utf8_lossy(&stderr_buf),
110 Err(e) => {
111 tracing::warn!("failed seek+read: {e}");
112 "<failed to read stderr>".into()
113 }
114 };
115 (&*r).to_owned()
116}
117
118impl ExitStatusExt for std::process::ExitStatus {
119 fn check_status(&mut self) -> Result<()> {
120 if self.success() {
121 return Ok(());
122 }
123 anyhow::bail!(format!("Subprocess failed: {self:?}"))
124 }
125 fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()> {
126 let stderr_buf = last_utf8_content_from_file(stderr);
127 if self.success() {
128 return Ok(());
129 }
130 anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
131 }
132}
133
134impl CommandRunExt for Command {
135 fn run_inherited(&mut self) -> Result<()> {
136 tracing::trace!("exec: {self:?}");
137 self.status()?.check_status()
138 }
139
140 fn run_capture_stderr(&mut self) -> Result<()> {
142 let stderr = tempfile::tempfile()?;
143 self.stderr(stderr.try_clone()?);
144 tracing::trace!("exec: {self:?}");
145 self.status()?.check_status_with_stderr(stderr)
146 }
147
148 #[allow(unsafe_code)]
149 fn lifecycle_bind(&mut self) -> &mut Self {
150 unsafe {
152 self.pre_exec(|| {
153 rustix::process::set_parent_process_death_signal(Some(
154 rustix::process::Signal::TERM,
155 ))
156 .map_err(Into::into)
157 })
158 }
159 }
160
161 fn log_debug(&mut self) -> &mut Self {
163 if !tracing::enabled!(tracing::Level::TRACE) {
165 tracing::debug!("exec: {self:?}");
166 }
167 self
168 }
169
170 fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>> {
171 let mut stdout = tempfile::tempfile()?;
172 self.stdout(stdout.try_clone()?);
173 self.run_capture_stderr()?;
174 stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?;
175 Ok(Box::new(std::io::BufReader::new(stdout)))
176 }
177
178 fn run_get_string(&mut self) -> Result<String> {
179 let mut s = String::new();
180 let mut o = self.run_get_output()?;
181 o.read_to_string(&mut s)?;
182 Ok(s)
183 }
184
185 fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T> {
187 let output = self.run_get_output()?;
188 serde_json::from_reader(output).map_err(Into::into)
189 }
190
191 fn run_inherited_with_cmd_context(&mut self) -> Result<()> {
192 self.status()?
193 .success()
194 .then_some(())
195 .context(format!("Failed to run command: {self:#?}"))
198 }
199
200 fn to_string_pretty(&self) -> String {
201 std::iter::once(self.get_program())
202 .chain(self.get_args())
203 .fold(String::new(), |mut acc, element| {
204 if !acc.is_empty() {
205 acc.push(' ');
206 }
207 write!(&mut acc, "{}", crate::PathQuotedDisplay::new(&element)).unwrap();
209 acc
210 })
211 }
212}
213
214#[allow(async_fn_in_trait)]
216pub trait AsyncCommandRunExt {
217 async fn run(&mut self) -> Result<()>;
219}
220
221impl AsyncCommandRunExt for tokio::process::Command {
222 async fn run(&mut self) -> Result<()> {
223 let stderr = tempfile::tempfile()?;
224 self.stderr(stderr.try_clone()?);
225 self.status().await?.check_status_with_stderr(stderr)
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn command_run_inherited() {
235 Command::new("true").run_inherited().unwrap();
237
238 assert!(Command::new("false").run_inherited().is_err());
240
241 let e = Command::new("/bin/sh")
243 .args(["-c", "echo should-not-be-captured 1>&2; exit 1"])
244 .run_inherited()
245 .err()
246 .unwrap();
247 assert_eq!(
249 e.to_string(),
250 "Subprocess failed: ExitStatus(unix_wait_status(256))"
251 );
252 }
253
254 #[test]
255 fn command_run_capture_stderr() {
256 Command::new("true").run_capture_stderr().unwrap();
258 assert!(Command::new("false").run_capture_stderr().is_err());
259
260 let e = Command::new("/bin/sh")
262 .args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
263 .run_capture_stderr()
264 .err()
265 .unwrap();
266 similar_asserts::assert_eq!(
267 e.to_string(),
268 "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
269 );
270
271 let e = Command::new("/bin/sh")
273 .args([
274 "-c",
275 r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
276 ])
277 .run_capture_stderr()
278 .err()
279 .unwrap();
280 similar_asserts::assert_eq!(
281 e.to_string(),
282 "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n"
283 );
284 }
285
286 #[test]
287 fn exit_status_check_status() {
288 use std::process::Command;
289
290 let mut success_status = Command::new("true").status().unwrap();
292 success_status.check_status().unwrap();
293
294 let mut fail_status = Command::new("false").status().unwrap();
296 let e = fail_status.check_status().err().unwrap();
297 assert_eq!(
298 e.to_string(),
299 "Subprocess failed: ExitStatus(unix_wait_status(256))"
300 );
301 }
302
303 #[test]
304 fn exit_status_check_status_with_stderr() {
305 use std::io::Write;
306 use std::process::Command;
307
308 let mut success_status = Command::new("true").status().unwrap();
310 let temp_stderr = tempfile::tempfile().unwrap();
311 success_status
312 .check_status_with_stderr(temp_stderr)
313 .unwrap();
314
315 let mut fail_status = Command::new("false").status().unwrap();
317 let mut temp_stderr = tempfile::tempfile().unwrap();
318 write!(temp_stderr, "test error message").unwrap();
319 let e = fail_status
320 .check_status_with_stderr(temp_stderr)
321 .err()
322 .unwrap();
323 assert!(
324 e.to_string()
325 .contains("Subprocess failed: ExitStatus(unix_wait_status(256))")
326 );
327 assert!(e.to_string().contains("test error message"));
328 }
329
330 #[test]
331 fn command_run_ext_json() {
332 #[derive(serde::Deserialize)]
333 struct Foo {
334 a: String,
335 b: u32,
336 }
337 let v: Foo = Command::new("echo")
338 .arg(r##"{"a": "somevalue", "b": 42}"##)
339 .run_and_parse_json()
340 .unwrap();
341 assert_eq!(v.a, "somevalue");
342 assert_eq!(v.b, 42);
343 }
344
345 #[tokio::test]
346 async fn async_command_run_ext() {
347 use tokio::process::Command as AsyncCommand;
348 let mut success = AsyncCommand::new("true");
349 let mut fail = AsyncCommand::new("false");
350 let (success, fail) = tokio::join!(success.run(), fail.run(),);
352 success.unwrap();
353 assert!(fail.is_err());
354 }
355
356 #[test]
357 fn to_string_pretty() {
358 let mut cmd = Command::new("podman");
359 cmd.args([
360 "run",
361 "--privileged",
362 "--pid=host",
363 "--user=root:root",
364 "-v",
365 "/var/lib/containers:/var/lib/containers",
366 "-v",
367 "this has spaces",
368 "label=type:unconfined_t",
369 "--env=RUST_LOG=trace",
370 "quay.io/ckyrouac/bootc-dev",
371 "bootc",
372 "install",
373 "to-existing-root",
374 ]);
375
376 similar_asserts::assert_eq!(
377 cmd.to_string_pretty(),
378 "podman run --privileged --pid=host --user=root:root -v /var/lib/containers:/var/lib/containers -v 'this has spaces' label=type:unconfined_t --env=RUST_LOG=trace quay.io/ckyrouac/bootc-dev bootc install to-existing-root"
379 );
380 }
381}