repro-env 0.1.0

Dependency lockfiles for a reproducible build environment 📦🔒
Documentation
use crate::errors::*;
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::fmt;
use std::io::Read;
use std::process::Stdio;
use std::str::FromStr;
use tokio::process::Command;

#[derive(Debug, PartialEq, Clone)]
pub struct ImageRef {
    pub repo: String,
    pub tag: Option<String>,
    pub digest: Option<String>,
}

impl FromStr for ImageRef {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        if let Some((repo, digest)) = s.split_once('@') {
            Ok(ImageRef {
                repo: repo.to_string(),
                tag: None,
                digest: Some(digest.to_string()),
            })
        } else if let Some((repo, tag)) = s.split_once(':') {
            Ok(ImageRef {
                repo: repo.to_string(),
                tag: Some(tag.to_string()),
                digest: None,
            })
        } else {
            Ok(ImageRef {
                repo: s.to_string(),
                tag: None,
                digest: None,
            })
        }
    }
}

impl ToString for ImageRef {
    fn to_string(&self) -> String {
        let repo = &self.repo;
        if let Some(digest) = &self.digest {
            format!("{repo}@{digest}")
        } else if let Some(tag) = &self.tag {
            format!("{repo}:{tag}")
        } else {
            repo.to_string()
        }
    }
}

pub async fn podman<I, S>(args: I, capture_stdout: bool) -> Result<Vec<u8>>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr> + fmt::Debug,
{
    let mut cmd = Command::new("podman");
    let args = args.into_iter().collect::<Vec<_>>();
    cmd.args(&args);
    if capture_stdout {
        cmd.stdout(Stdio::piped());
    }
    debug!("Spawning child process: podman {:?}", args);
    let child = cmd.spawn().context("Failed to execute podman binary")?;

    let out = child.wait_with_output().await?;
    debug!("Podman command exited: {:?}", out.status);
    if !out.status.success() {
        bail!(
            "Podman command ({:?}) failed to execute: {:?}",
            args,
            out.status
        );
    }
    Ok(out.stdout)
}

pub async fn pull(image: &str) -> Result<()> {
    podman(&["image", "pull", "--", image], false).await?;
    Ok(())
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Image {
    pub digest: String,
}

pub async fn inspect(image: &str) -> Result<Vec<Image>> {
    let inspect = podman(&["image", "inspect", "--", image], true).await?;
    let inspect = serde_json::from_slice::<Vec<Image>>(&inspect)?;
    debug!("Image inspect result: {inspect:?}");
    Ok(inspect)
}

#[derive(Debug)]
pub struct Config<'a> {
    pub init: &'a [String],
    pub mounts: &'a [(String, String)],
    pub expose_fuse: bool,
}

#[derive(Debug, Default)]
pub struct Exec<'a> {
    pub capture_stdout: bool,
    pub cwd: Option<&'a str>,
    pub user: Option<&'a str>,
}

#[derive(Debug)]
pub struct Container {
    pub id: String,
}

impl Container {
    pub async fn create(image: &str, config: Config<'_>) -> Result<Container> {
        let bin = config
            .init
            .first()
            .context("Command for container can't be empty")?;
        let cmd_args = &config.init[1..];
        let entrypoint = format!("--entrypoint={bin}");
        let mut podman_args = vec![
            "container".to_string(),
            "run".to_string(),
            "--detach".to_string(),
            "--rm".to_string(),
            "--network=host".to_string(),
            "-v=/usr/bin/catatonit:/__:ro".to_string(),
        ];

        for (src, dest) in config.mounts {
            podman_args.push(format!("-v={src}:{dest}"));
        }

        if config.expose_fuse {
            debug!("Mapping /dev/fuse into the container");
            podman_args.push("--device=/dev/fuse".to_string());
        }

        podman_args.extend([entrypoint, "--".to_string(), image.to_string()]);
        podman_args.extend(cmd_args.iter().map(|s| s.to_string()));

        let mut out = podman(&podman_args, true).await?;
        if let Some(idx) = memchr::memchr(b'\n', &out) {
            out.truncate(idx);
        }
        let id = String::from_utf8(out)?;
        Ok(Container { id })
    }

    pub async fn exec<I, S>(&self, args: I, options: Exec<'_>) -> Result<Vec<u8>>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str> + fmt::Debug + Clone,
    {
        let args = args.into_iter().collect::<Vec<_>>();
        let mut a = vec!["container".to_string(), "exec".to_string()];
        if let Some(cwd) = options.cwd {
            a.extend(["-w".to_string(), cwd.to_string()]);
        }
        if let Some(user) = options.user {
            a.extend(["-u".to_string(), user.to_string()]);
        }
        a.extend(["--".to_string(), self.id.to_string()]);
        a.extend(args.iter().map(|x| x.as_ref().to_string()));
        let buf = podman(&a, options.capture_stdout)
            .await
            .with_context(|| anyhow!("Failed to execute in container: {:?}", args))?;
        Ok(buf)
    }

    pub async fn cat(&self, path: &str) -> Result<Vec<u8>> {
        let a = vec![
            "container".to_string(),
            "cp".to_string(),
            "--".to_string(),
            format!("{}:{}", self.id, path),
            "-".to_string(),
        ];
        let buf = podman(&a, true)
            .await
            .with_context(|| anyhow!("Failed to read from container: {:?}", path))?;

        let mut tar = tar::Archive::new(&buf[..]);
        let mut entries = tar.entries()?;
        let entry = entries
            .next()
            .context("Tar archive generated by podman cp is empty")?;
        let mut entry = entry?;

        let mut buf = Vec::new();
        entry.read_to_end(&mut buf)?;

        Ok(buf)
    }

    pub async fn kill(self) -> Result<()> {
        podman(&["container", "kill", &self.id], true)
            .await
            .context("Failed to remove container")?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_image_ref() -> Result<()> {
        let image_ref = ImageRef::from_str("rust")?;
        assert_eq!(
            image_ref,
            ImageRef {
                repo: "rust".to_string(),
                tag: None,
                digest: None,
            }
        );
        Ok(())
    }

    #[test]
    fn test_parse_image_ref_digest() -> Result<()> {
        let image_ref = ImageRef::from_str(
            "rust@sha256:28ee8822965a932e229599b59928f8c2655b2a198af30568acf63e8aff0e8a3a",
        )?;
        assert_eq!(
            image_ref,
            ImageRef {
                repo: "rust".to_string(),
                tag: None,
                digest: Some(
                    "sha256:28ee8822965a932e229599b59928f8c2655b2a198af30568acf63e8aff0e8a3a"
                        .to_string()
                ),
            }
        );
        Ok(())
    }

    #[test]
    fn test_parse_image_ref_tag() -> Result<()> {
        let image_ref = ImageRef::from_str("rust:1-alpine3.18")?;
        assert_eq!(
            image_ref,
            ImageRef {
                repo: "rust".to_string(),
                tag: Some("1-alpine3.18".to_string()),
                digest: None,
            }
        );
        Ok(())
    }
}