Skip to main content

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