1use crate::{
2 error::{binding_env_var, ErrorData, Result},
3 traits::{Binding, Build},
4};
5use alien_core::{bindings::BuildBinding, BuildConfig, BuildExecution, BuildStatus};
6use alien_error::{AlienError, Context, IntoAlienError};
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use std::{
10 process::Stdio,
11 time::{SystemTime, UNIX_EPOCH},
12};
13use tokio::process::Command;
14use uuid::Uuid;
15
16#[derive(Debug, Serialize, Deserialize)]
17struct BuildMetadata {
18 uuid: String,
19 pid: u32,
20 start_time: String,
21 end_time: Option<String>,
22 status: BuildStatus,
23}
24
25#[derive(Debug)]
29pub struct LocalBuild {
30 binding_name: String,
31 base_dir: std::path::PathBuf,
32 build_env_vars: std::collections::HashMap<String, String>,
33}
34
35impl LocalBuild {
36 pub fn new(binding_name: String, binding: alien_core::bindings::BuildBinding) -> Result<Self> {
38 let config = match binding {
40 BuildBinding::Local(config) => config,
41 _ => {
42 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
43 env_var: binding_env_var(&binding_name),
44 binding_name: binding_name.clone(),
45 reason: "Expected Local binding, got different service type".to_string(),
46 }));
47 }
48 };
49
50 let data_dir = config
51 .data_dir
52 .into_value(&binding_name, "data_dir")
53 .context(ErrorData::BindingConfigInvalid {
54 env_var: binding_env_var(&binding_name),
55 binding_name: binding_name.clone(),
56 reason: "Failed to extract data_dir from binding".to_string(),
57 })?;
58
59 let build_env_vars = config
60 .build_env_vars
61 .into_value(&binding_name, "build_env_vars")
62 .context(ErrorData::BindingConfigInvalid {
63 env_var: binding_env_var(&binding_name),
64 binding_name: binding_name.clone(),
65 reason: "Failed to extract build_env_vars from binding".to_string(),
66 })?;
67
68 let base_dir = std::path::PathBuf::from(data_dir).join(&binding_name);
69
70 std::fs::create_dir_all(&base_dir)
72 .into_alien_error()
73 .context(ErrorData::BindingConfigInvalid {
74 env_var: binding_env_var(&binding_name),
75 binding_name: binding_name.clone(),
76 reason: "Failed to create build directory".to_string(),
77 })?;
78
79 Ok(Self {
80 binding_name,
81 base_dir,
82 build_env_vars,
83 })
84 }
85
86 pub fn new_from_path(binding_name: String, base_dir: std::path::PathBuf) -> Self {
88 Self {
89 binding_name,
90 base_dir,
91 build_env_vars: std::collections::HashMap::new(),
92 }
93 }
94
95 fn encode_build_id(uuid: &str, pid: u32, timestamp: u64) -> String {
97 format!("{}_{}_{}", uuid, pid, timestamp)
98 }
99
100 fn decode_build_id(build_id: &str) -> Result<(String, u32, u64)> {
102 let parts: Vec<&str> = build_id.split('_').collect();
103 if parts.len() != 3 {
104 return Err(AlienError::new(ErrorData::BuildOperationFailed {
105 binding_name: "local".to_string(),
106 operation: format!("invalid build ID format: {}", build_id),
107 }));
108 }
109
110 let uuid = parts[0].to_string();
111 let pid = parts[1].parse::<u32>().map_err(|_| {
112 AlienError::new(ErrorData::BuildOperationFailed {
113 binding_name: "local".to_string(),
114 operation: format!("invalid PID in build ID: {}", build_id),
115 })
116 })?;
117 let timestamp = parts[2].parse::<u64>().map_err(|_| {
118 AlienError::new(ErrorData::BuildOperationFailed {
119 binding_name: "local".to_string(),
120 operation: format!("invalid timestamp in build ID: {}", build_id),
121 })
122 })?;
123
124 Ok((uuid, pid, timestamp))
125 }
126
127 fn create_build_dir(&self, uuid: &str) -> Result<std::path::PathBuf> {
129 let build_dir = self.base_dir.join("builds").join(uuid);
130 std::fs::create_dir_all(&build_dir)
131 .into_alien_error()
132 .context(ErrorData::BuildOperationFailed {
133 binding_name: self.binding_name.clone(),
134 operation: "create build directory".to_string(),
135 })?;
136 Ok(build_dir)
137 }
138
139 fn save_build_metadata(&self, uuid: &str, metadata: &BuildMetadata) -> Result<()> {
141 let build_dir = self.base_dir.join("builds").join(uuid);
142 let metadata_path = build_dir.join("metadata.json");
143
144 let metadata_json = serde_json::to_string_pretty(metadata)
145 .into_alien_error()
146 .context(ErrorData::BuildOperationFailed {
147 binding_name: self.binding_name.clone(),
148 operation: "serialize build metadata".to_string(),
149 })?;
150
151 std::fs::write(&metadata_path, metadata_json)
152 .into_alien_error()
153 .context(ErrorData::BuildOperationFailed {
154 binding_name: self.binding_name.clone(),
155 operation: "write build metadata".to_string(),
156 })?;
157
158 Ok(())
159 }
160
161 fn load_build_metadata(&self, uuid: &str) -> Result<BuildMetadata> {
163 let build_dir = self.base_dir.join("builds").join(uuid);
164 let metadata_path = build_dir.join("metadata.json");
165
166 let metadata_json = std::fs::read_to_string(&metadata_path)
167 .into_alien_error()
168 .context(ErrorData::BuildOperationFailed {
169 binding_name: self.binding_name.clone(),
170 operation: format!("read build metadata for {}", uuid),
171 })?;
172
173 let metadata: BuildMetadata = serde_json::from_str(&metadata_json)
174 .into_alien_error()
175 .context(ErrorData::BuildOperationFailed {
176 binding_name: self.binding_name.clone(),
177 operation: format!("parse build metadata for {}", uuid),
178 })?;
179
180 Ok(metadata)
181 }
182
183 fn is_process_running(&self, pid: u32) -> bool {
185 #[cfg(unix)]
186 {
187 use std::process::Command;
188 Command::new("kill")
190 .args(["-0", &pid.to_string()])
191 .output()
192 .map(|output| output.status.success())
193 .unwrap_or(false)
194 }
195
196 #[cfg(windows)]
197 {
198 use std::process::Command;
199 Command::new("tasklist")
201 .args(["/FI", &format!("PID eq {}", pid)])
202 .output()
203 .map(|output| {
204 output.status.success()
205 && String::from_utf8_lossy(&output.stdout).contains(&pid.to_string())
206 })
207 .unwrap_or(false)
208 }
209 }
210
211 fn update_build_status(&self, metadata: &mut BuildMetadata) -> Result<()> {
213 if metadata.status == BuildStatus::Running {
214 if !self.is_process_running(metadata.pid) {
215 metadata.status = BuildStatus::Succeeded; metadata.end_time = Some(chrono::Utc::now().to_rfc3339());
218 self.save_build_metadata(&metadata.uuid, metadata)?;
219 }
220 }
221 Ok(())
222 }
223}
224
225#[async_trait]
226impl Build for LocalBuild {
227 async fn start_build(&self, config: BuildConfig) -> Result<BuildExecution> {
228 let uuid = Uuid::new_v4().to_string();
229 let start_time = chrono::Utc::now().to_rfc3339();
230 let timestamp = SystemTime::now()
231 .duration_since(UNIX_EPOCH)
232 .unwrap()
233 .as_secs();
234
235 let build_dir = self.create_build_dir(&uuid)?;
237
238 let script_path = build_dir.join("build_script.sh");
240 std::fs::write(&script_path, &config.script)
241 .into_alien_error()
242 .context(ErrorData::BuildOperationFailed {
243 binding_name: self.binding_name.clone(),
244 operation: "write build script".to_string(),
245 })?;
246
247 #[cfg(unix)]
249 {
250 use std::os::unix::fs::PermissionsExt;
251 let mut perms = std::fs::metadata(&script_path)
252 .into_alien_error()
253 .context(ErrorData::BuildOperationFailed {
254 binding_name: self.binding_name.clone(),
255 operation: "get script permissions".to_string(),
256 })?
257 .permissions();
258 perms.set_mode(0o755);
259 std::fs::set_permissions(&script_path, perms)
260 .into_alien_error()
261 .context(ErrorData::BuildOperationFailed {
262 binding_name: self.binding_name.clone(),
263 operation: "set script permissions".to_string(),
264 })?;
265 }
266
267 let mut cmd = Command::new("bash");
269 cmd.arg(&script_path)
270 .current_dir(&build_dir)
271 .stdin(Stdio::null())
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped());
274
275 let mut merged_environment = self.build_env_vars.clone();
278 merged_environment.extend(config.environment);
279
280 for (key, value) in &merged_environment {
282 cmd.env(key, value);
283 }
284
285 let mut child =
287 cmd.spawn()
288 .into_alien_error()
289 .context(ErrorData::BuildOperationFailed {
290 binding_name: self.binding_name.clone(),
291 operation: "start build process".to_string(),
292 })?;
293
294 let pid = child.id().ok_or_else(|| {
296 AlienError::new(ErrorData::BuildOperationFailed {
297 binding_name: self.binding_name.clone(),
298 operation: "get process ID".to_string(),
299 })
300 })?;
301
302 let build_id = Self::encode_build_id(&uuid, pid, timestamp);
304
305 let metadata = BuildMetadata {
307 uuid: uuid.clone(),
308 pid,
309 start_time: start_time.clone(),
310 end_time: None,
311 status: BuildStatus::Running,
312 };
313 self.save_build_metadata(&uuid, &metadata)?;
314
315 tokio::spawn(async move {
317 let _ = child.wait().await;
318 });
319
320 Ok(BuildExecution {
321 id: build_id,
322 status: BuildStatus::Running,
323 start_time: Some(start_time),
324 end_time: None,
325 })
326 }
327
328 async fn get_build_status(&self, build_id: &str) -> Result<BuildExecution> {
329 let (uuid, _pid, _timestamp) = Self::decode_build_id(build_id)?;
331
332 let mut metadata = self.load_build_metadata(&uuid)?;
334
335 self.update_build_status(&mut metadata)?;
337
338 Ok(BuildExecution {
339 id: build_id.to_string(),
340 status: metadata.status,
341 start_time: Some(metadata.start_time),
342 end_time: metadata.end_time,
343 })
344 }
345
346 async fn stop_build(&self, build_id: &str) -> Result<()> {
347 let (uuid, pid, _timestamp) = Self::decode_build_id(build_id)?;
349
350 let mut metadata = self.load_build_metadata(&uuid)?;
352
353 if metadata.status == BuildStatus::Running {
355 #[cfg(unix)]
356 {
357 use std::process::Command;
358 let _ = Command::new("kill")
360 .args(["-TERM", &pid.to_string()])
361 .output();
362 }
363
364 #[cfg(windows)]
365 {
366 use std::process::Command;
367 let _ = Command::new("taskkill")
369 .args(["/PID", &pid.to_string(), "/F"])
370 .output();
371 }
372
373 metadata.status = BuildStatus::Cancelled;
375 metadata.end_time = Some(chrono::Utc::now().to_rfc3339());
376 self.save_build_metadata(&uuid, &metadata)?;
377 }
378
379 Ok(())
380 }
381}
382
383impl Binding for LocalBuild {}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use std::collections::HashMap;
389 use tempfile::TempDir;
390
391 #[tokio::test]
392 async fn test_local_build_success() {
393 let temp_dir = TempDir::new().unwrap();
394 let local_build =
395 LocalBuild::new_from_path("test-build".to_string(), temp_dir.path().to_path_buf());
396
397 let mut config = BuildConfig {
398 image: "ubuntu:20.04".to_string(), script: "echo 'Hello World!'".to_string(),
400 environment: HashMap::new(),
401 timeout_seconds: 30,
402 compute_type: alien_core::ComputeType::Small,
403 monitoring: None,
404 };
405 config
406 .environment
407 .insert("TEST_VAR".to_string(), "test_value".to_string());
408
409 let execution = local_build.start_build(config).await.unwrap();
410 assert!(!execution.id.is_empty());
411 assert_eq!(execution.status, BuildStatus::Running);
412
413 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
415
416 let status = local_build.get_build_status(&execution.id).await.unwrap();
417 assert_eq!(status.status, BuildStatus::Succeeded);
418 assert!(status.end_time.is_some());
419 }
420
421 #[tokio::test]
422 async fn test_local_build_failure() {
423 let temp_dir = TempDir::new().unwrap();
424 let local_build =
425 LocalBuild::new_from_path("test-build".to_string(), temp_dir.path().to_path_buf());
426
427 let config = BuildConfig {
428 image: "ubuntu:20.04".to_string(), script: "exit 1".to_string(), environment: HashMap::new(),
431 timeout_seconds: 30,
432 compute_type: alien_core::ComputeType::Small,
433 monitoring: None,
434 };
435
436 let execution = local_build.start_build(config).await.unwrap();
437 assert!(!execution.id.is_empty());
438 assert_eq!(execution.status, BuildStatus::Running);
439
440 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
442
443 let status = local_build.get_build_status(&execution.id).await.unwrap();
444 assert_eq!(status.status, BuildStatus::Succeeded); assert!(status.end_time.is_some());
446 }
447
448 #[tokio::test]
449 async fn test_local_build_stop() {
450 let temp_dir = TempDir::new().unwrap();
451 let local_build =
452 LocalBuild::new_from_path("test-build".to_string(), temp_dir.path().to_path_buf());
453
454 let config = BuildConfig {
455 image: "ubuntu:20.04".to_string(), script: "sleep 10".to_string(), environment: HashMap::new(),
458 timeout_seconds: 30,
459 compute_type: alien_core::ComputeType::Small,
460 monitoring: None,
461 };
462
463 let execution = local_build.start_build(config).await.unwrap();
464 assert!(!execution.id.is_empty());
465 assert_eq!(execution.status, BuildStatus::Running);
466
467 local_build.stop_build(&execution.id).await.unwrap();
469
470 let status = local_build.get_build_status(&execution.id).await.unwrap();
471 assert_eq!(status.status, BuildStatus::Cancelled);
472 assert!(status.end_time.is_some());
473 }
474
475 #[test]
476 fn test_build_id_encoding_decoding() {
477 let uuid = "550e8400-e29b-41d4-a716-446655440000";
478 let pid = 12345u32;
479 let timestamp = 1234567890u64;
480
481 let build_id = LocalBuild::encode_build_id(uuid, pid, timestamp);
482 let (decoded_uuid, decoded_pid, decoded_timestamp) =
483 LocalBuild::decode_build_id(&build_id).unwrap();
484
485 assert_eq!(decoded_uuid, uuid);
486 assert_eq!(decoded_pid, pid);
487 assert_eq!(decoded_timestamp, timestamp);
488 }
489}