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
12fn command_output_file() -> Result<std::fs::File> {
14 rustix::fs::memfd_create("bootc-command-output", rustix::fs::MemfdFlags::CLOEXEC)
18 .map(std::fs::File::from)
19 .context("create memfd for command output")
20}
21
22pub trait CommandRunExt {
24 fn log_debug(&mut self) -> &mut Self;
26
27 fn run_inherited(&mut self) -> Result<()>;
37
38 fn run_capture_stderr(&mut self) -> Result<()>;
49
50 fn run_inherited_with_cmd_context(&mut self) -> Result<()>;
61
62 fn lifecycle_bind(&mut self) -> &mut Self;
64
65 fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>>;
68
69 fn run_get_string(&mut self) -> Result<String>;
72
73 fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T>;
76
77 fn to_string_pretty(&self) -> String;
79}
80
81pub trait ExitStatusExt {
83 fn check_status(&mut self) -> Result<()>;
88
89 fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()>;
94}
95
96fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
101 const MAX_STDERR_BYTES: u16 = 1024;
104 let size = f
105 .metadata()
106 .map_err(|e| {
107 tracing::warn!("failed to fstat: {e}");
108 })
109 .map(|m| m.len().try_into().unwrap_or(u16::MAX))
110 .unwrap_or(0);
111 let size = size.min(MAX_STDERR_BYTES);
112 let seek_offset = -(size as i32);
113 let mut stderr_buf = Vec::with_capacity(size.into());
114 let r = match f
116 .seek(std::io::SeekFrom::End(seek_offset.into()))
117 .and_then(|_| f.read_to_end(&mut stderr_buf))
118 {
119 Ok(_) => String::from_utf8_lossy(&stderr_buf),
120 Err(e) => {
121 tracing::warn!("failed seek+read: {e}");
122 "<failed to read stderr>".into()
123 }
124 };
125 (&*r).to_owned()
126}
127
128impl ExitStatusExt for std::process::ExitStatus {
129 fn check_status(&mut self) -> Result<()> {
130 if self.success() {
131 return Ok(());
132 }
133 anyhow::bail!(format!("Subprocess failed: {self:?}"))
134 }
135 fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()> {
136 let stderr_buf = last_utf8_content_from_file(stderr);
137 if self.success() {
138 return Ok(());
139 }
140 anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
141 }
142}
143
144impl CommandRunExt for Command {
145 fn run_inherited(&mut self) -> Result<()> {
146 tracing::trace!("exec: {self:?}");
147 self.status()?.check_status()
148 }
149
150 fn run_capture_stderr(&mut self) -> Result<()> {
152 let stderr = command_output_file()?;
153 self.stderr(stderr.try_clone()?);
154 tracing::trace!("exec: {self:?}");
155 self.status()?.check_status_with_stderr(stderr)
156 }
157
158 #[allow(unsafe_code)]
159 fn lifecycle_bind(&mut self) -> &mut Self {
160 unsafe {
162 self.pre_exec(|| {
163 rustix::process::set_parent_process_death_signal(Some(
164 rustix::process::Signal::TERM,
165 ))
166 .map_err(Into::into)
167 })
168 }
169 }
170
171 fn log_debug(&mut self) -> &mut Self {
173 if !tracing::enabled!(tracing::Level::TRACE) {
175 tracing::debug!("exec: {self:?}");
176 }
177 self
178 }
179
180 fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>> {
181 let mut stdout = command_output_file()?;
182 self.stdout(stdout.try_clone()?);
183 self.run_capture_stderr()?;
184 stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?;
185 Ok(Box::new(std::io::BufReader::new(stdout)))
186 }
187
188 fn run_get_string(&mut self) -> Result<String> {
189 let mut s = String::new();
190 let mut o = self.run_get_output()?;
191 o.read_to_string(&mut s)?;
192 Ok(s)
193 }
194
195 fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T> {
197 let output = self.run_get_output()?;
198 serde_json::from_reader(output).map_err(Into::into)
199 }
200
201 fn run_inherited_with_cmd_context(&mut self) -> Result<()> {
202 self.status()?
203 .success()
204 .then_some(())
205 .context(format!("Failed to run command: {self:#?}"))
208 }
209
210 fn to_string_pretty(&self) -> String {
211 std::iter::once(self.get_program())
212 .chain(self.get_args())
213 .fold(String::new(), |mut acc, element| {
214 if !acc.is_empty() {
215 acc.push(' ');
216 }
217 write!(&mut acc, "{}", crate::PathQuotedDisplay::new(&element)).unwrap();
219 acc
220 })
221 }
222}
223
224#[allow(async_fn_in_trait)]
226pub trait AsyncCommandRunExt {
227 async fn run(&mut self) -> Result<()>;
229}
230
231impl AsyncCommandRunExt for tokio::process::Command {
232 async fn run(&mut self) -> Result<()> {
233 let stderr = command_output_file()?;
234 self.stderr(stderr.try_clone()?);
235 self.status().await?.check_status_with_stderr(stderr)
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn command_run_inherited() {
245 Command::new("true").run_inherited().unwrap();
247
248 assert!(Command::new("false").run_inherited().is_err());
250
251 let e = Command::new("/bin/sh")
253 .args(["-c", "echo should-not-be-captured 1>&2; exit 1"])
254 .run_inherited()
255 .err()
256 .unwrap();
257 assert_eq!(
259 e.to_string(),
260 "Subprocess failed: ExitStatus(unix_wait_status(256))"
261 );
262 }
263
264 #[test]
265 fn command_run_capture_stderr() {
266 Command::new("true").run_capture_stderr().unwrap();
268 assert!(Command::new("false").run_capture_stderr().is_err());
269
270 let e = Command::new("/bin/sh")
272 .args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
273 .run_capture_stderr()
274 .err()
275 .unwrap();
276 similar_asserts::assert_eq!(
277 e.to_string(),
278 "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
279 );
280
281 let e = Command::new("/bin/sh")
283 .args([
284 "-c",
285 r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
286 ])
287 .run_capture_stderr()
288 .err()
289 .unwrap();
290 similar_asserts::assert_eq!(
291 e.to_string(),
292 "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n"
293 );
294 }
295
296 #[test]
297 fn command_output_file_is_a_memfd() {
298 use std::os::fd::AsRawFd;
299
300 let file = command_output_file().unwrap();
301 let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd());
304 let target = std::fs::read_link(fd_path).unwrap();
305 assert!(
306 target
307 .to_string_lossy()
308 .contains("memfd:bootc-command-output")
309 );
310 }
311
312 #[test]
313 fn exit_status_check_status() {
314 use std::process::Command;
315
316 let mut success_status = Command::new("true").status().unwrap();
318 success_status.check_status().unwrap();
319
320 let mut fail_status = Command::new("false").status().unwrap();
322 let e = fail_status.check_status().err().unwrap();
323 assert_eq!(
324 e.to_string(),
325 "Subprocess failed: ExitStatus(unix_wait_status(256))"
326 );
327 }
328
329 #[test]
330 fn exit_status_check_status_with_stderr() {
331 use std::io::Write;
332 use std::process::Command;
333
334 let mut success_status = Command::new("true").status().unwrap();
336 let temp_stderr = command_output_file().unwrap();
337 success_status
338 .check_status_with_stderr(temp_stderr)
339 .unwrap();
340
341 let mut fail_status = Command::new("false").status().unwrap();
343 let mut temp_stderr = command_output_file().unwrap();
344 write!(temp_stderr, "test error message").unwrap();
345 let e = fail_status
346 .check_status_with_stderr(temp_stderr)
347 .err()
348 .unwrap();
349 assert!(
350 e.to_string()
351 .contains("Subprocess failed: ExitStatus(unix_wait_status(256))")
352 );
353 assert!(e.to_string().contains("test error message"));
354 }
355
356 #[test]
357 fn command_run_ext_json() {
358 #[derive(serde::Deserialize)]
359 struct Foo {
360 a: String,
361 b: u32,
362 }
363 let v: Foo = Command::new("echo")
364 .arg(r##"{"a": "somevalue", "b": 42}"##)
365 .run_and_parse_json()
366 .unwrap();
367 assert_eq!(v.a, "somevalue");
368 assert_eq!(v.b, 42);
369 }
370
371 #[tokio::test]
372 async fn async_command_run_ext() {
373 use tokio::process::Command as AsyncCommand;
374 let mut success = AsyncCommand::new("true");
375 let mut fail = AsyncCommand::new("false");
376 let (success, fail) = tokio::join!(success.run(), fail.run(),);
378 success.unwrap();
379 assert!(fail.is_err());
380
381 let error = AsyncCommand::new("/bin/sh")
382 .args(["-c", "echo expected-async-error 1>&2; exit 1"])
383 .run()
384 .await
385 .unwrap_err();
386 assert!(error.to_string().contains("expected-async-error"));
387 }
388
389 #[test]
390 fn to_string_pretty() {
391 let mut cmd = Command::new("podman");
392 cmd.args([
393 "run",
394 "--privileged",
395 "--pid=host",
396 "--user=root:root",
397 "-v",
398 "/var/lib/containers:/var/lib/containers",
399 "-v",
400 "this has spaces",
401 "label=type:unconfined_t",
402 "--env=RUST_LOG=trace",
403 "quay.io/ckyrouac/bootc-dev",
404 "bootc",
405 "install",
406 "to-existing-root",
407 ]);
408
409 similar_asserts::assert_eq!(
410 cmd.to_string_pretty(),
411 "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"
412 );
413 }
414}