ciab-packer 0.1.0

HashiCorp Packer image builder for CIAB
Documentation
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;

use async_trait::async_trait;
use chrono::Utc;
use dashmap::DashMap;
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
use tracing::info;
use uuid::Uuid;

use ciab_core::error::{CiabError, CiabResult};
use ciab_core::traits::image_builder::ImageBuilder;
use ciab_core::types::config::PackerConfig;
use ciab_core::types::image::{
    BuiltImage, ImageBuildRequest, ImageBuildResult, ImageBuildStatus,
};

use crate::template;

struct BuildState {
    status: ImageBuildStatus,
    image_id: Option<String>,
    logs: Vec<String>,
}

pub struct PackerImageBuilder {
    config: PackerConfig,
    builds: Arc<DashMap<Uuid, BuildState>>,
    images: Arc<DashMap<String, BuiltImage>>,
}

impl PackerImageBuilder {
    pub fn new(config: PackerConfig) -> Self {
        Self {
            config,
            builds: Arc::new(DashMap::new()),
            images: Arc::new(DashMap::new()),
        }
    }

    async fn packer_binary(&self) -> CiabResult<String> {
        let check = Command::new("which")
            .arg(&self.config.binary)
            .output()
            .await;

        if let Ok(output) = check {
            if output.status.success() {
                return Ok(self.config.binary.clone());
            }
        }

        if self.config.auto_install {
            info!("Packer not found, attempting auto-install");
            self.install_packer().await?;
            Ok(self.config.binary.clone())
        } else {
            Err(CiabError::PackerError(format!(
                "Packer binary '{}' not found. Set auto_install = true to install automatically.",
                self.config.binary
            )))
        }
    }

    async fn install_packer(&self) -> CiabResult<()> {
        let output = Command::new("sh")
            .arg("-c")
            .arg(
                "curl -fsSL https://releases.hashicorp.com/packer/1.11.2/packer_1.11.2_linux_amd64.zip -o /tmp/packer.zip \
                 && unzip -o /tmp/packer.zip -d /usr/local/bin/ \
                 && rm /tmp/packer.zip",
            )
            .output()
            .await
            .map_err(|e| CiabError::PackerError(format!("Failed to install packer: {}", e)))?;

        if !output.status.success() {
            return Err(CiabError::PackerError(format!(
                "Packer install failed: {}",
                String::from_utf8_lossy(&output.stderr)
            )));
        }

        info!("Packer installed successfully");
        Ok(())
    }

    fn merge_variables(&self, request: &ImageBuildRequest) -> HashMap<String, String> {
        let mut vars = self.config.variables.clone();
        vars.extend(request.variables.clone());
        vars
    }

    fn build_command_args(
        &self,
        binary: &str,
        template_path: &std::path::Path,
        variables: &HashMap<String, String>,
    ) -> Command {
        let mut cmd = Command::new(binary);
        cmd.arg("build");
        cmd.arg("-machine-readable");

        for (key, value) in variables {
            cmd.arg("-var");
            cmd.arg(format!("{}={}", key, value));
        }

        cmd.arg(template_path);
        cmd.stdout(Stdio::piped());
        cmd.stderr(Stdio::piped());
        cmd
    }

    fn parse_artifact_id(line: &str) -> Option<String> {
        let parts: Vec<&str> = line.split(',').collect();
        if parts.len() >= 5 && parts[2] == "artifact" && parts[4] == "id" {
            let id_part = parts.get(5).unwrap_or(&"");
            if let Some((_region, ami)) = id_part.split_once(':') {
                return Some(ami.to_string());
            }
            return Some(id_part.to_string());
        }
        None
    }
}

#[async_trait]
impl ImageBuilder for PackerImageBuilder {
    async fn build_image(&self, request: &ImageBuildRequest) -> CiabResult<ImageBuildResult> {
        let build_id = Uuid::new_v4();
        info!(build_id = %build_id, "Starting Packer image build");

        self.builds.insert(
            build_id,
            BuildState {
                status: ImageBuildStatus::Running,
                image_id: None,
                logs: Vec::new(),
            },
        );

        let template_content = template::resolve_template(&request.template, &self.config).await?;
        let template_path = template::write_temp_template(&template_content).await?;

        let binary = self.packer_binary().await?;
        let variables = self.merge_variables(request);

        let mut cmd = self.build_command_args(&binary, &template_path, &variables);
        let mut child = cmd
            .spawn()
            .map_err(|e| CiabError::PackerError(format!("Failed to spawn packer: {}", e)))?;

        let stdout = child.stdout.take().ok_or_else(|| {
            CiabError::PackerError("Failed to capture packer stdout".to_string())
        })?;

        let builds = self.builds.clone();
        let images = self.images.clone();
        let build_id_clone = build_id;
        let tags = request.tags.clone();

        tokio::spawn(async move {
            let reader = tokio::io::BufReader::new(stdout);
            let mut lines = reader.lines();
            let mut artifact_id: Option<String> = None;

            while let Ok(Some(line)) = lines.next_line().await {
                if let Some(id) = PackerImageBuilder::parse_artifact_id(&line) {
                    artifact_id = Some(id);
                }
                if let Some(mut build) = builds.get_mut(&build_id_clone) {
                    build.logs.push(line);
                }
            }

            let status = child.wait().await;
            let success = status.map(|s| s.success()).unwrap_or(false);

            if let Some(mut build) = builds.get_mut(&build_id_clone) {
                if success {
                    build.status = ImageBuildStatus::Succeeded;
                    build.image_id = artifact_id.clone();
                    if let Some(ref image_id) = artifact_id {
                        images.insert(
                            image_id.clone(),
                            BuiltImage {
                                image_id: image_id.clone(),
                                provider: "amazon-ebs".to_string(),
                                region: None,
                                created_at: Utc::now(),
                                tags: tags.clone(),
                            },
                        );
                    }
                } else {
                    let err_msg = build
                        .logs
                        .last()
                        .cloned()
                        .unwrap_or_else(|| "Unknown error".to_string());
                    build.status = ImageBuildStatus::Failed(err_msg);
                }
            }
        });

        Ok(ImageBuildResult {
            build_id,
            status: ImageBuildStatus::Running,
            image_id: None,
            logs: Vec::new(),
        })
    }

    async fn list_images(&self) -> CiabResult<Vec<BuiltImage>> {
        Ok(self.images.iter().map(|r| r.value().clone()).collect())
    }

    async fn delete_image(&self, image_id: &str) -> CiabResult<()> {
        self.images.remove(image_id);
        info!(image_id = image_id, "Removed image from local registry");
        Ok(())
    }

    async fn build_status(&self, build_id: &Uuid) -> CiabResult<ImageBuildStatus> {
        self.builds
            .get(build_id)
            .map(|b| b.status.clone())
            .ok_or_else(|| CiabError::ImageBuildError(format!("Build {} not found", build_id)))
    }
}

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

    #[test]
    fn test_parse_artifact_id_valid() {
        let line = "1234567890,amazon-ebs.agent,artifact,0,id,us-east-1:ami-0123456789abcdef0";
        let result = PackerImageBuilder::parse_artifact_id(line);
        assert_eq!(result, Some("ami-0123456789abcdef0".to_string()));
    }

    #[test]
    fn test_parse_artifact_id_no_match() {
        let line = "1234567890,amazon-ebs.agent,ui,message,Building AMI...";
        let result = PackerImageBuilder::parse_artifact_id(line);
        assert_eq!(result, None);
    }

    #[test]
    fn test_merge_variables() {
        let config = PackerConfig {
            binary: "packer".to_string(),
            auto_install: false,
            template_cache_dir: "/tmp".to_string(),
            template_cache_ttl_secs: 3600,
            default_template: "builtin://default-ec2".to_string(),
            variables: HashMap::from([
                ("region".to_string(), "us-east-1".to_string()),
                ("instance_type".to_string(), "t3.small".to_string()),
            ]),
        };
        let builder = PackerImageBuilder::new(config);
        let request = ImageBuildRequest {
            template: None,
            variables: HashMap::from([
                ("instance_type".to_string(), "t3.large".to_string()),
                ("base_ami".to_string(), "ami-123".to_string()),
            ]),
            agent_provider: None,
            tags: HashMap::new(),
        };
        let merged = builder.merge_variables(&request);
        assert_eq!(merged.get("region"), Some(&"us-east-1".to_string()));
        assert_eq!(merged.get("instance_type"), Some(&"t3.large".to_string()));
        assert_eq!(merged.get("base_ami"), Some(&"ami-123".to_string()));
    }
}