Skip to main content

ciab_packer/
builder.rs

1use std::collections::HashMap;
2use std::process::Stdio;
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use chrono::Utc;
7use dashmap::DashMap;
8use tokio::io::AsyncBufReadExt;
9use tokio::process::Command;
10use tracing::info;
11use uuid::Uuid;
12
13use ciab_core::error::{CiabError, CiabResult};
14use ciab_core::traits::image_builder::ImageBuilder;
15use ciab_core::types::config::PackerConfig;
16use ciab_core::types::image::{
17    BuiltImage, ImageBuildRequest, ImageBuildResult, ImageBuildStatus,
18};
19
20use crate::template;
21
22struct BuildState {
23    status: ImageBuildStatus,
24    image_id: Option<String>,
25    logs: Vec<String>,
26}
27
28pub struct PackerImageBuilder {
29    config: PackerConfig,
30    builds: Arc<DashMap<Uuid, BuildState>>,
31    images: Arc<DashMap<String, BuiltImage>>,
32}
33
34impl PackerImageBuilder {
35    pub fn new(config: PackerConfig) -> Self {
36        Self {
37            config,
38            builds: Arc::new(DashMap::new()),
39            images: Arc::new(DashMap::new()),
40        }
41    }
42
43    async fn packer_binary(&self) -> CiabResult<String> {
44        let check = Command::new("which")
45            .arg(&self.config.binary)
46            .output()
47            .await;
48
49        if let Ok(output) = check {
50            if output.status.success() {
51                return Ok(self.config.binary.clone());
52            }
53        }
54
55        if self.config.auto_install {
56            info!("Packer not found, attempting auto-install");
57            self.install_packer().await?;
58            Ok(self.config.binary.clone())
59        } else {
60            Err(CiabError::PackerError(format!(
61                "Packer binary '{}' not found. Set auto_install = true to install automatically.",
62                self.config.binary
63            )))
64        }
65    }
66
67    async fn install_packer(&self) -> CiabResult<()> {
68        let output = Command::new("sh")
69            .arg("-c")
70            .arg(
71                "curl -fsSL https://releases.hashicorp.com/packer/1.11.2/packer_1.11.2_linux_amd64.zip -o /tmp/packer.zip \
72                 && unzip -o /tmp/packer.zip -d /usr/local/bin/ \
73                 && rm /tmp/packer.zip",
74            )
75            .output()
76            .await
77            .map_err(|e| CiabError::PackerError(format!("Failed to install packer: {}", e)))?;
78
79        if !output.status.success() {
80            return Err(CiabError::PackerError(format!(
81                "Packer install failed: {}",
82                String::from_utf8_lossy(&output.stderr)
83            )));
84        }
85
86        info!("Packer installed successfully");
87        Ok(())
88    }
89
90    fn merge_variables(&self, request: &ImageBuildRequest) -> HashMap<String, String> {
91        let mut vars = self.config.variables.clone();
92        vars.extend(request.variables.clone());
93        vars
94    }
95
96    fn build_command_args(
97        &self,
98        binary: &str,
99        template_path: &std::path::Path,
100        variables: &HashMap<String, String>,
101    ) -> Command {
102        let mut cmd = Command::new(binary);
103        cmd.arg("build");
104        cmd.arg("-machine-readable");
105
106        for (key, value) in variables {
107            cmd.arg("-var");
108            cmd.arg(format!("{}={}", key, value));
109        }
110
111        cmd.arg(template_path);
112        cmd.stdout(Stdio::piped());
113        cmd.stderr(Stdio::piped());
114        cmd
115    }
116
117    fn parse_artifact_id(line: &str) -> Option<String> {
118        let parts: Vec<&str> = line.split(',').collect();
119        if parts.len() >= 5 && parts[2] == "artifact" && parts[4] == "id" {
120            let id_part = parts.get(5).unwrap_or(&"");
121            if let Some((_region, ami)) = id_part.split_once(':') {
122                return Some(ami.to_string());
123            }
124            return Some(id_part.to_string());
125        }
126        None
127    }
128}
129
130#[async_trait]
131impl ImageBuilder for PackerImageBuilder {
132    async fn build_image(&self, request: &ImageBuildRequest) -> CiabResult<ImageBuildResult> {
133        let build_id = Uuid::new_v4();
134        info!(build_id = %build_id, "Starting Packer image build");
135
136        self.builds.insert(
137            build_id,
138            BuildState {
139                status: ImageBuildStatus::Running,
140                image_id: None,
141                logs: Vec::new(),
142            },
143        );
144
145        let template_content = template::resolve_template(&request.template, &self.config).await?;
146        let template_path = template::write_temp_template(&template_content).await?;
147
148        let binary = self.packer_binary().await?;
149        let variables = self.merge_variables(request);
150
151        let mut cmd = self.build_command_args(&binary, &template_path, &variables);
152        let mut child = cmd
153            .spawn()
154            .map_err(|e| CiabError::PackerError(format!("Failed to spawn packer: {}", e)))?;
155
156        let stdout = child.stdout.take().ok_or_else(|| {
157            CiabError::PackerError("Failed to capture packer stdout".to_string())
158        })?;
159
160        let builds = self.builds.clone();
161        let images = self.images.clone();
162        let build_id_clone = build_id;
163        let tags = request.tags.clone();
164
165        tokio::spawn(async move {
166            let reader = tokio::io::BufReader::new(stdout);
167            let mut lines = reader.lines();
168            let mut artifact_id: Option<String> = None;
169
170            while let Ok(Some(line)) = lines.next_line().await {
171                if let Some(id) = PackerImageBuilder::parse_artifact_id(&line) {
172                    artifact_id = Some(id);
173                }
174                if let Some(mut build) = builds.get_mut(&build_id_clone) {
175                    build.logs.push(line);
176                }
177            }
178
179            let status = child.wait().await;
180            let success = status.map(|s| s.success()).unwrap_or(false);
181
182            if let Some(mut build) = builds.get_mut(&build_id_clone) {
183                if success {
184                    build.status = ImageBuildStatus::Succeeded;
185                    build.image_id = artifact_id.clone();
186                    if let Some(ref image_id) = artifact_id {
187                        images.insert(
188                            image_id.clone(),
189                            BuiltImage {
190                                image_id: image_id.clone(),
191                                provider: "amazon-ebs".to_string(),
192                                region: None,
193                                created_at: Utc::now(),
194                                tags: tags.clone(),
195                            },
196                        );
197                    }
198                } else {
199                    let err_msg = build
200                        .logs
201                        .last()
202                        .cloned()
203                        .unwrap_or_else(|| "Unknown error".to_string());
204                    build.status = ImageBuildStatus::Failed(err_msg);
205                }
206            }
207        });
208
209        Ok(ImageBuildResult {
210            build_id,
211            status: ImageBuildStatus::Running,
212            image_id: None,
213            logs: Vec::new(),
214        })
215    }
216
217    async fn list_images(&self) -> CiabResult<Vec<BuiltImage>> {
218        Ok(self.images.iter().map(|r| r.value().clone()).collect())
219    }
220
221    async fn delete_image(&self, image_id: &str) -> CiabResult<()> {
222        self.images.remove(image_id);
223        info!(image_id = image_id, "Removed image from local registry");
224        Ok(())
225    }
226
227    async fn build_status(&self, build_id: &Uuid) -> CiabResult<ImageBuildStatus> {
228        self.builds
229            .get(build_id)
230            .map(|b| b.status.clone())
231            .ok_or_else(|| CiabError::ImageBuildError(format!("Build {} not found", build_id)))
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_parse_artifact_id_valid() {
241        let line = "1234567890,amazon-ebs.agent,artifact,0,id,us-east-1:ami-0123456789abcdef0";
242        let result = PackerImageBuilder::parse_artifact_id(line);
243        assert_eq!(result, Some("ami-0123456789abcdef0".to_string()));
244    }
245
246    #[test]
247    fn test_parse_artifact_id_no_match() {
248        let line = "1234567890,amazon-ebs.agent,ui,message,Building AMI...";
249        let result = PackerImageBuilder::parse_artifact_id(line);
250        assert_eq!(result, None);
251    }
252
253    #[test]
254    fn test_merge_variables() {
255        let config = PackerConfig {
256            binary: "packer".to_string(),
257            auto_install: false,
258            template_cache_dir: "/tmp".to_string(),
259            template_cache_ttl_secs: 3600,
260            default_template: "builtin://default-ec2".to_string(),
261            variables: HashMap::from([
262                ("region".to_string(), "us-east-1".to_string()),
263                ("instance_type".to_string(), "t3.small".to_string()),
264            ]),
265        };
266        let builder = PackerImageBuilder::new(config);
267        let request = ImageBuildRequest {
268            template: None,
269            variables: HashMap::from([
270                ("instance_type".to_string(), "t3.large".to_string()),
271                ("base_ami".to_string(), "ami-123".to_string()),
272            ]),
273            agent_provider: None,
274            tags: HashMap::new(),
275        };
276        let merged = builder.merge_variables(&request);
277        assert_eq!(merged.get("region"), Some(&"us-east-1".to_string()));
278        assert_eq!(merged.get("instance_type"), Some(&"t3.large".to_string()));
279        assert_eq!(merged.get("base_ami"), Some(&"ami-123".to_string()));
280    }
281}