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}