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(&mut self) -> Result<()>;
19
20 fn run_with_cmd_context(&mut self) -> Result<()>;
23
24 fn lifecycle_bind(&mut self) -> &mut Self;
26
27 fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>>;
30
31 fn run_get_string(&mut self) -> Result<String>;
33
34 fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T>;
37
38 fn to_string_pretty(&self) -> String;
40}
41
42pub trait ExitStatusExt {
44 fn check_status(&mut self, stderr: std::fs::File) -> Result<()>;
49}
50
51fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
56 const MAX_STDERR_BYTES: u16 = 1024;
59 let size = f
60 .metadata()
61 .map_err(|e| {
62 tracing::warn!("failed to fstat: {e}");
63 })
64 .map(|m| m.len().try_into().unwrap_or(u16::MAX))
65 .unwrap_or(0);
66 let size = size.min(MAX_STDERR_BYTES);
67 let seek_offset = -(size as i32);
68 let mut stderr_buf = Vec::with_capacity(size.into());
69 let r = match f
71 .seek(std::io::SeekFrom::End(seek_offset.into()))
72 .and_then(|_| f.read_to_end(&mut stderr_buf))
73 {
74 Ok(_) => String::from_utf8_lossy(&stderr_buf),
75 Err(e) => {
76 tracing::warn!("failed seek+read: {e}");
77 "<failed to read stderr>".into()
78 }
79 };
80 (&*r).to_owned()
81}
82
83impl ExitStatusExt for std::process::ExitStatus {
84 fn check_status(&mut self, stderr: std::fs::File) -> Result<()> {
85 let stderr_buf = last_utf8_content_from_file(stderr);
86 if self.success() {
87 return Ok(());
88 }
89 anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
90 }
91}
92
93impl CommandRunExt for Command {
94 fn run(&mut self) -> Result<()> {
96 let stderr = tempfile::tempfile()?;
97 self.stderr(stderr.try_clone()?);
98 tracing::trace!("exec: {self:?}");
99 self.status()?.check_status(stderr)
100 }
101
102 #[allow(unsafe_code)]
103 fn lifecycle_bind(&mut self) -> &mut Self {
104 unsafe {
106 self.pre_exec(|| {
107 rustix::process::set_parent_process_death_signal(Some(
108 rustix::process::Signal::TERM,
109 ))
110 .map_err(Into::into)
111 })
112 }
113 }
114
115 fn log_debug(&mut self) -> &mut Self {
117 if !tracing::enabled!(tracing::Level::TRACE) {
119 tracing::debug!("exec: {self:?}");
120 }
121 self
122 }
123
124 fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>> {
125 let mut stdout = tempfile::tempfile()?;
126 self.stdout(stdout.try_clone()?);
127 self.run()?;
128 stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?;
129 Ok(Box::new(std::io::BufReader::new(stdout)))
130 }
131
132 fn run_get_string(&mut self) -> Result<String> {
133 let mut s = String::new();
134 let mut o = self.run_get_output()?;
135 o.read_to_string(&mut s)?;
136 Ok(s)
137 }
138
139 fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T> {
141 let output = self.run_get_output()?;
142 serde_json::from_reader(output).map_err(Into::into)
143 }
144
145 fn run_with_cmd_context(&mut self) -> Result<()> {
146 self.status()?
147 .success()
148 .then_some(())
149 .context(format!("Failed to run command: {self:#?}"))
152 }
153
154 fn to_string_pretty(&self) -> String {
155 std::iter::once(self.get_program())
156 .chain(self.get_args())
157 .fold(String::new(), |mut acc, element| {
158 if !acc.is_empty() {
159 acc.push(' ');
160 }
161 write!(&mut acc, "{}", crate::PathQuotedDisplay::new(&element)).unwrap();
163 acc
164 })
165 }
166}
167
168#[allow(async_fn_in_trait)]
170pub trait AsyncCommandRunExt {
171 async fn run(&mut self) -> Result<()>;
173}
174
175impl AsyncCommandRunExt for tokio::process::Command {
176 async fn run(&mut self) -> Result<()> {
177 let stderr = tempfile::tempfile()?;
178 self.stderr(stderr.try_clone()?);
179 self.status().await?.check_status(stderr)
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn command_run_ext() {
189 Command::new("true").run().unwrap();
191 assert!(Command::new("false").run().is_err());
192
193 let e = Command::new("/bin/sh")
195 .args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
196 .run()
197 .err()
198 .unwrap();
199 similar_asserts::assert_eq!(
200 e.to_string(),
201 "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
202 );
203
204 let e = Command::new("/bin/sh")
206 .args([
207 "-c",
208 r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
209 ])
210 .run()
211 .err()
212 .unwrap();
213 similar_asserts::assert_eq!(
214 e.to_string(),
215 "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n"
216 );
217 }
218
219 #[test]
220 fn command_run_ext_json() {
221 #[derive(serde::Deserialize)]
222 struct Foo {
223 a: String,
224 b: u32,
225 }
226 let v: Foo = Command::new("echo")
227 .arg(r##"{"a": "somevalue", "b": 42}"##)
228 .run_and_parse_json()
229 .unwrap();
230 assert_eq!(v.a, "somevalue");
231 assert_eq!(v.b, 42);
232 }
233
234 #[tokio::test]
235 async fn async_command_run_ext() {
236 use tokio::process::Command as AsyncCommand;
237 let mut success = AsyncCommand::new("true");
238 let mut fail = AsyncCommand::new("false");
239 let (success, fail) = tokio::join!(success.run(), fail.run(),);
241 success.unwrap();
242 assert!(fail.is_err());
243 }
244
245 #[test]
246 fn to_string_pretty() {
247 let mut cmd = Command::new("podman");
248 cmd.args([
249 "run",
250 "--privileged",
251 "--pid=host",
252 "--user=root:root",
253 "-v",
254 "/var/lib/containers:/var/lib/containers",
255 "-v",
256 "this has spaces",
257 "label=type:unconfined_t",
258 "--env=RUST_LOG=trace",
259 "quay.io/ckyrouac/bootc-dev",
260 "bootc",
261 "install",
262 "to-existing-root",
263 ]);
264
265 similar_asserts::assert_eq!(cmd.to_string_pretty(), "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");
266 }
267}