Skip to main content

alien_bindings/providers/build/
local.rs

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