my-ci 0.0.1

Minimalist Local CICD
use std::fs::File;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use bollard::Docker;
use bollard::image::BuildImageOptions;
use bytes::Bytes;
use futures_util::StreamExt;
use tar::{Builder, Header};
use walkdir::WalkDir;

use crate::config::{WorkflowConfig, WorkflowFile, image_tag, normalize_context};

pub async fn build_workflow(
    docker: &Docker,
    config: &WorkflowFile,
    wf: &WorkflowConfig,
) -> Result<()> {
    let context = normalize_context(&wf.context);
    let image_tag = image_tag(config, wf);
    println!("Building '{}' from {}", wf.name, context.display());

    let tar_path = write_temp_build_context(&context, &wf.instructions)?;
    let archive_bytes = std::fs::read(&tar_path)
        .with_context(|| format!("failed to read temp build context {}", tar_path.display()))?;
    let body = Bytes::from(archive_bytes);

    let options = BuildImageOptions {
        dockerfile: "Dockerfile".to_string(),
        t: image_tag.clone(),
        rm: true,
        forcerm: true,
        pull: true,
        ..Default::default()
    };

    let mut output = docker.build_image(options, None, Some(body));
    while let Some(item) = output.next().await {
        let chunk = item.context("docker build stream failed")?;
        if let Some(error) = chunk.error {
            bail!("build failed for '{}': {error}", wf.name);
        }
        if let Some(stream) = chunk.stream {
            print!("{stream}");
        }
    }

    std::fs::remove_file(&tar_path).ok();
    Ok(())
}

fn write_temp_build_context(context: &Path, dockerfile: &str) -> Result<PathBuf> {
    let mut archive_path = std::env::temp_dir();
    archive_path.push(format!(
        "my-ci-{}.tar",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0)
    ));

    let tar_file = File::create(&archive_path)
        .with_context(|| format!("failed to create {}", archive_path.display()))?;
    let mut builder = Builder::new(tar_file);
    append_directory_to_tar(context, &mut builder)?;
    append_virtual_file(&mut builder, "Dockerfile", dockerfile.as_bytes())?;
    builder.finish().context("failed to finalize build archive")?;
    Ok(archive_path)
}

fn append_directory_to_tar(context: &Path, builder: &mut Builder<File>) -> Result<()> {
    for entry in WalkDir::new(context) {
        let entry = entry?;
        let path = entry.path();
        let rel = path.strip_prefix(context).with_context(|| {
            format!(
                "failed to strip context prefix '{}' from '{}'",
                context.display(),
                path.display()
            )
        })?;
        if rel.as_os_str().is_empty() || rel.starts_with(".git") || rel.starts_with("target") {
            continue;
        }

        if entry.file_type().is_dir() {
            builder
                .append_dir(rel, path)
                .with_context(|| format!("failed to append directory {}", path.display()))?;
            continue;
        }

        if entry.file_type().is_file() {
            let mut file = File::open(path)
                .with_context(|| format!("failed to open context file {}", path.display()))?;
            let mut data = Vec::new();
            file.read_to_end(&mut data)
                .with_context(|| format!("failed to read context file {}", path.display()))?;
            append_virtual_file(builder, rel, &data)?;
        }
    }
    Ok(())
}

fn append_virtual_file(
    builder: &mut Builder<File>,
    rel: impl AsRef<Path>,
    contents: &[u8],
) -> Result<()> {
    let mut header = Header::new_gnu();
    header.set_size(contents.len() as u64);
    header.set_mode(0o644);
    header.set_cksum();
    let mut cursor = Cursor::new(contents);
    builder
        .append_data(&mut header, rel, &mut cursor)
        .context("failed to append file to tar")?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::io::Read;
    use tar::Archive;

    fn tempdir() -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!(
            "my-ci-build-test-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    fn read_archive(path: &Path) -> HashMap<String, Vec<u8>> {
        let mut map = HashMap::new();
        let file = File::open(path).unwrap();
        let mut archive = Archive::new(file);
        for entry in archive.entries().unwrap() {
            let mut entry = entry.unwrap();
            let name = entry.path().unwrap().display().to_string();
            let mut buf = Vec::new();
            entry.read_to_end(&mut buf).unwrap();
            map.insert(name, buf);
        }
        map
    }

    #[test]
    fn tar_includes_dockerfile_and_context_files() {
        let dir = tempdir();
        std::fs::write(dir.join("hello.txt"), b"hi").unwrap();
        std::fs::create_dir(dir.join("nested")).unwrap();
        std::fs::write(dir.join("nested/data.txt"), b"data").unwrap();

        let tar = write_temp_build_context(&dir, "FROM busybox\n").unwrap();
        let entries = read_archive(&tar);

        assert_eq!(
            entries.get("Dockerfile").map(|v| v.as_slice()),
            Some(b"FROM busybox\n".as_slice())
        );
        assert_eq!(
            entries.get("hello.txt").map(|v| v.as_slice()),
            Some(b"hi".as_slice())
        );
        assert_eq!(
            entries.get("nested/data.txt").map(|v| v.as_slice()),
            Some(b"data".as_slice())
        );
        std::fs::remove_file(&tar).ok();
    }

    #[test]
    fn tar_skips_target_and_git() {
        let dir = tempdir();
        std::fs::create_dir_all(dir.join("target/debug")).unwrap();
        std::fs::write(dir.join("target/debug/blob"), b"x").unwrap();
        std::fs::create_dir_all(dir.join(".git")).unwrap();
        std::fs::write(dir.join(".git/HEAD"), b"x").unwrap();
        std::fs::write(dir.join("keep.txt"), b"keep").unwrap();

        let tar = write_temp_build_context(&dir, "FROM busybox\n").unwrap();
        let entries = read_archive(&tar);

        assert!(entries.contains_key("keep.txt"));
        assert!(entries.keys().all(|k| !k.starts_with("target")));
        assert!(entries.keys().all(|k| !k.starts_with(".git")));
        std::fs::remove_file(&tar).ok();
    }
}