repro_env/
container.rs

1use crate::errors::*;
2use serde::{Deserialize, Serialize};
3use std::ffi::OsStr;
4use std::fmt;
5use std::future::{self, Future};
6use std::io::Read;
7use std::process::Stdio;
8use std::str::FromStr;
9use tokio::io::AsyncWriteExt;
10use tokio::process::Command;
11use tokio::signal;
12
13#[derive(Debug, PartialEq, Clone)]
14pub struct ImageRef {
15    pub repo: String,
16    pub tag: Option<String>,
17    pub digest: Option<String>,
18}
19
20impl FromStr for ImageRef {
21    type Err = Error;
22
23    fn from_str(s: &str) -> Result<Self> {
24        if let Some((repo, digest)) = s.split_once('@') {
25            Ok(ImageRef {
26                repo: repo.to_string(),
27                tag: None,
28                digest: Some(digest.to_string()),
29            })
30        } else if let Some((repo, tag)) = s.split_once(':') {
31            Ok(ImageRef {
32                repo: repo.to_string(),
33                tag: Some(tag.to_string()),
34                digest: None,
35            })
36        } else {
37            Ok(ImageRef {
38                repo: s.to_string(),
39                tag: None,
40                digest: None,
41            })
42        }
43    }
44}
45
46impl fmt::Display for ImageRef {
47    fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
48        let repo = &self.repo;
49        if let Some(digest) = &self.digest {
50            write!(w, "{repo}@{digest}")
51        } else if let Some(tag) = &self.tag {
52            write!(w, "{repo}:{tag}")
53        } else {
54            write!(w, "{repo}")
55        }
56    }
57}
58
59#[derive(Debug, Default)]
60pub struct ExecConfig {
61    pub capture_stdout: bool,
62    pub silence_stderr: bool,
63    pub stdin: Option<Vec<u8>>,
64}
65
66pub async fn podman<I, S>(args: I, config: &ExecConfig) -> Result<Vec<u8>>
67where
68    I: IntoIterator<Item = S>,
69    S: AsRef<OsStr> + fmt::Debug,
70{
71    let mut cmd = Command::new("podman");
72    let args = args.into_iter().collect::<Vec<_>>();
73    cmd.args(&args);
74    if config.stdin.is_some() {
75        cmd.stdin(Stdio::piped());
76    }
77    if config.capture_stdout {
78        cmd.stdout(Stdio::piped());
79    }
80    if config.silence_stderr {
81        cmd.stderr(Stdio::null());
82    }
83    debug!("Spawning child process: podman {:?}", args);
84    let mut child = cmd.spawn().context("Failed to execute podman binary")?;
85
86    // write to stdin (if configured)
87    if let Some(buf) = &config.stdin {
88        if let Some(mut stdin) = child.stdin.take() {
89            stdin.write_all(buf).await?;
90        }
91    }
92
93    // wait for the process to exit
94    let out = child.wait_with_output().await?;
95    debug!("Podman command exited: {:?}", out.status);
96    if !out.status.success() {
97        bail!(
98            "Podman command ({:?}) failed to execute: {:?}",
99            args,
100            out.status
101        );
102    }
103    Ok(out.stdout)
104}
105
106pub async fn pull(image: &str) -> Result<()> {
107    podman(&["image", "pull", "--", image], &ExecConfig::default()).await?;
108    Ok(())
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "PascalCase")]
113pub struct Image {
114    pub digest: String,
115}
116
117pub async fn inspect(image: &str) -> Result<Image> {
118    let inspect = podman(
119        &["image", "inspect", "--", image],
120        &ExecConfig {
121            capture_stdout: true,
122            silence_stderr: true,
123            ..Default::default()
124        },
125    )
126    .await?;
127    let mut list = serde_json::from_slice::<Vec<Image>>(&inspect)?;
128    debug!("Image inspect result: {list:?}");
129
130    let inspect = list
131        .pop()
132        .with_context(|| anyhow!("Could not find any matching image: {image:?}"))?;
133
134    match list.len() {
135        0 => Ok(inspect),
136        len => bail!(
137            "The specified image is not canonical, inspect returned {}, expected 1",
138            len + 1
139        ),
140    }
141}
142
143#[derive(Debug)]
144pub struct Config<'a> {
145    pub mounts: &'a [(String, String)],
146    pub expose_fuse: bool,
147}
148
149#[derive(Debug, Default)]
150pub struct Exec<'a> {
151    pub capture_stdout: bool,
152    pub cwd: Option<&'a str>,
153    pub user: Option<&'a str>,
154    pub env: &'a [String],
155}
156
157#[derive(Debug)]
158pub struct Container {
159    pub id: String,
160}
161
162impl Container {
163    pub async fn create(image: &str, config: Config<'_>) -> Result<Container> {
164        let mut podman_args = vec![
165            "container".to_string(),
166            "run".to_string(),
167            "--detach".to_string(),
168            "--rm".to_string(),
169            "--network=host".to_string(),
170            "-v=/usr/bin/catatonit:/__:ro".to_string(),
171            "--entrypoint=/__".to_string(),
172        ];
173
174        for (src, dest) in config.mounts {
175            podman_args.push(format!("-v={src}:{dest}"));
176        }
177
178        if config.expose_fuse {
179            debug!("Mapping /dev/fuse into the container");
180            podman_args.push("--device=/dev/fuse".to_string());
181        }
182
183        podman_args.extend(["--".to_string(), image.to_string(), "-P".to_string()]);
184
185        debug!("Creating container...");
186        let mut out = podman(
187            &podman_args,
188            &ExecConfig {
189                capture_stdout: true,
190                ..Default::default()
191            },
192        )
193        .await?;
194        if let Some(idx) = memchr::memchr(b'\n', &out) {
195            out.truncate(idx);
196        }
197        let id = String::from_utf8(out)?;
198        Ok(Container { id })
199    }
200
201    pub async fn exec<I, S>(&self, args: I, options: Exec<'_>) -> Result<Vec<u8>>
202    where
203        I: IntoIterator<Item = S>,
204        S: AsRef<str> + fmt::Debug + Clone,
205    {
206        let args = args.into_iter().collect::<Vec<_>>();
207        let mut a = vec!["container".to_string(), "exec".to_string()];
208
209        if let Some(cwd) = options.cwd {
210            a.extend(["-w".to_string(), cwd.to_string()]);
211        }
212
213        if let Some(user) = options.user {
214            a.extend(["-u".to_string(), user.to_string()]);
215        }
216
217        for env in options.env {
218            a.extend(["-e".to_string(), env.to_string()]);
219        }
220
221        a.extend(["--".to_string(), self.id.to_string()]);
222        a.extend(args.iter().map(|x| x.as_ref().to_string()));
223        let buf = podman(
224            &a,
225            &ExecConfig {
226                capture_stdout: options.capture_stdout,
227                ..Default::default()
228            },
229        )
230        .await
231        .with_context(|| anyhow!("Failed to execute in container: {:?}", args))?;
232        Ok(buf)
233    }
234
235    pub async fn tar(&self, path: &str) -> Result<Vec<u8>> {
236        let a = vec![
237            "container".to_string(),
238            "cp".to_string(),
239            "--".to_string(),
240            format!("{}:{}", self.id, path),
241            "-".to_string(),
242        ];
243        let buf = podman(
244            &a,
245            &ExecConfig {
246                capture_stdout: true,
247                ..Default::default()
248            },
249        )
250        .await
251        .with_context(|| anyhow!("Failed to read from container: {:?}", path))?;
252
253        Ok(buf)
254    }
255
256    pub async fn cat(&self, path: &str) -> Result<Vec<u8>> {
257        let buf = self.tar(path).await?;
258
259        let mut tar = tar::Archive::new(&buf[..]);
260        let mut entries = tar.entries()?;
261        let entry = entries
262            .next()
263            .context("Tar archive generated by podman cp is empty")?;
264        let mut entry = entry?;
265
266        let entry_type = entry.header().entry_type();
267        if entry_type != tar::EntryType::Regular {
268            bail!("Extracted file is not of type file: {entry_type:?}");
269        }
270
271        let mut buf = Vec::new();
272        entry.read_to_end(&mut buf)?;
273
274        Ok(buf)
275    }
276
277    pub async fn write_file(&self, directory: &str, filename: &str, content: &[u8]) -> Result<()> {
278        // generate tar file
279        let mut tar = tar::Builder::new(Vec::new());
280
281        let mut header = tar::Header::new_gnu();
282        header.set_size(content.len() as u64);
283        header.set_mode(0o640);
284
285        debug!(
286            "Adding to archive: {:?} ({} bytes)",
287            filename,
288            content.len()
289        );
290        tar.append_data(&mut header, filename, content)?;
291        let buf = tar.into_inner()?;
292
293        // pass archive into container
294        let a = vec![
295            "container".to_string(),
296            "cp".to_string(),
297            "--".to_string(),
298            "-".to_string(),
299            format!("{}:{}", self.id, directory),
300        ];
301        podman(
302            &a,
303            &ExecConfig {
304                stdin: Some(buf),
305                ..Default::default()
306            },
307        )
308        .await
309        .with_context(|| {
310            anyhow!("Failed to write container (directory={directory:?}, filename={filename:?}")
311        })?;
312
313        Ok(())
314    }
315
316    pub async fn kill(&self) -> Result<()> {
317        podman(
318            &["container", "kill", &self.id],
319            &ExecConfig {
320                capture_stdout: true,
321                ..Default::default()
322            },
323        )
324        .await
325        .context("Failed to remove container")?;
326        Ok(())
327    }
328
329    pub async fn run<F: Future<Output = Result<()>>>(&self, fut: F, keep: bool) -> Result<()> {
330        let fut = async {
331            fut.await?;
332            if keep {
333                info!("Keeping container around until ^C...");
334                future::pending().await
335            } else {
336                Ok(())
337            }
338        };
339        let result = tokio::select! {
340            result = fut => result,
341            _ = signal::ctrl_c() => Err(anyhow!("Ctrl-c received")),
342        };
343        debug!("Removing container...");
344        if let Err(err) = self.kill().await {
345            warn!("Failed to kill container {:?}: {:#}", self.id, err);
346        }
347        debug!("Container cleanup complete");
348        result
349    }
350}
351
352#[cfg(target_os = "linux")]
353pub fn test_userns_clone() -> Result<()> {
354    use nix::sched::CloneFlags;
355    use nix::sys::wait::{WaitPidFlag, WaitStatus};
356
357    let cb = Box::new(|| 0);
358    let stack = &mut [0; 1024];
359    let flags = CloneFlags::CLONE_NEWNS | CloneFlags::CLONE_NEWUSER;
360
361    let pid = unsafe { nix::sched::clone(cb, stack, flags, None) }
362        .context("Failed to create user namespace")?;
363    let status = nix::sys::wait::waitpid(pid, Some(WaitPidFlag::__WCLONE))
364        .context("Failed to reap child")?;
365
366    if status != WaitStatus::Exited(pid, 0) {
367        bail!("Unexpected wait result: {:?}", status);
368    }
369
370    Ok(())
371}
372
373#[cfg(target_os = "linux")]
374pub async fn test_for_unprivileged_userns_clone() -> Result<()> {
375    if std::env::var("REPRO_ENV_SKIP_CLONE_CHECK")
376        .map(|x| x != "0")
377        .unwrap_or(false)
378    {
379        debug!("Skipping test if user namespaces can be created");
380        return Ok(());
381    }
382
383    debug!("Testing if user namespaces can be created");
384    if let Err(err) = test_userns_clone() {
385        match tokio::fs::read("/proc/sys/kernel/unprivileged_userns_clone").await {
386            Ok(buf) => {
387                if buf == b"0\n" {
388                    warn!("User namespaces are not enabled in /proc/sys/kernel/unprivileged_userns_clone")
389                }
390            }
391            Err(err) => warn!(
392                "Failed to check if unprivileged_userns_clone are allowed: {:#}",
393                err
394            ),
395        }
396
397        Err(err)
398    } else {
399        debug!("Successfully tested for user namespaces");
400        Ok(())
401    }
402}
403
404#[cfg(not(target_os = "linux"))]
405pub async fn test_for_unprivileged_userns_clone() -> Result<()> {
406    Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_parse_image_ref() -> Result<()> {
415        let image_ref = ImageRef::from_str("rust")?;
416        assert_eq!(
417            image_ref,
418            ImageRef {
419                repo: "rust".to_string(),
420                tag: None,
421                digest: None,
422            }
423        );
424        Ok(())
425    }
426
427    #[test]
428    fn test_parse_image_ref_digest() -> Result<()> {
429        let image_ref = ImageRef::from_str(
430            "rust@sha256:28ee8822965a932e229599b59928f8c2655b2a198af30568acf63e8aff0e8a3a",
431        )?;
432        assert_eq!(
433            image_ref,
434            ImageRef {
435                repo: "rust".to_string(),
436                tag: None,
437                digest: Some(
438                    "sha256:28ee8822965a932e229599b59928f8c2655b2a198af30568acf63e8aff0e8a3a"
439                        .to_string()
440                ),
441            }
442        );
443        Ok(())
444    }
445
446    #[test]
447    fn test_parse_image_ref_tag() -> Result<()> {
448        let image_ref = ImageRef::from_str("rust:1-alpine3.18")?;
449        assert_eq!(
450            image_ref,
451            ImageRef {
452                repo: "rust".to_string(),
453                tag: Some("1-alpine3.18".to_string()),
454                digest: None,
455            }
456        );
457        Ok(())
458    }
459}