Skip to main content

alien_bindings/providers/build/
local.rs

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/// Local implementation of the `Build` trait that runs bash scripts directly.
26/// This implementation is stateless - all build state is encoded in the build ID
27/// and stored in the filesystem.
28#[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    /// Creates a new Local Build instance from binding parameters.
37    pub fn new(binding_name: String, binding: alien_core::bindings::BuildBinding) -> Result<Self> {
38        // Extract values from binding
39        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        // 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                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    /// Creates a new Local Build instance from a directory path.
87    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    /// Encodes build information into a build ID: {uuid}_{pid}_{timestamp}
96    fn encode_build_id(uuid: &str, pid: u32, timestamp: u64) -> String {
97        format!("{}_{}_{}", uuid, pid, timestamp)
98    }
99
100    /// Decodes build information from a build ID
101    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    /// Creates a working directory for a build
128    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    /// Saves build metadata to disk
140    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    /// Loads build metadata from disk
162    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    /// Checks if a process is still running
184    fn is_process_running(&self, pid: u32) -> bool {
185        #[cfg(unix)]
186        {
187            use std::process::Command;
188            // Use kill -0 to check if process exists without actually killing it
189            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            // Use tasklist to check if process exists
200            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    /// Updates build status based on current process state
212    fn update_build_status(&self, metadata: &mut BuildMetadata) -> Result<()> {
213        if metadata.status == BuildStatus::Running {
214            if !self.is_process_running(metadata.pid) {
215                // Process has finished, update status
216                metadata.status = BuildStatus::Succeeded; // Assume success if process exited cleanly
217                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        // Create working directory for the build
236        let build_dir = self.create_build_dir(&uuid)?;
237
238        // Create script file
239        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        // Make script executable
248        #[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        // Prepare environment variables
268        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        // Merge build config environment with binding environment variables
276        // Build config environment takes precedence over binding environment
277        let mut merged_environment = self.build_env_vars.clone();
278        merged_environment.extend(config.environment);
279
280        // Add environment variables
281        for (key, value) in &merged_environment {
282            cmd.env(key, value);
283        }
284
285        // Start the process
286        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        // Get the PID
295        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        // Create build ID with encoded PID
303        let build_id = Self::encode_build_id(&uuid, pid, timestamp);
304
305        // Save build metadata
306        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        // Detach the child process so it runs independently
316        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        // Decode build ID to get UUID and PID
330        let (uuid, _pid, _timestamp) = Self::decode_build_id(build_id)?;
331
332        // Load build metadata
333        let mut metadata = self.load_build_metadata(&uuid)?;
334
335        // Update status based on current process state
336        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        // Decode build ID to get UUID and PID
348        let (uuid, pid, _timestamp) = Self::decode_build_id(build_id)?;
349
350        // Load build metadata
351        let mut metadata = self.load_build_metadata(&uuid)?;
352
353        // Only try to kill if the build is still running
354        if metadata.status == BuildStatus::Running {
355            #[cfg(unix)]
356            {
357                use std::process::Command;
358                // Kill the process
359                let _ = Command::new("kill")
360                    .args(["-TERM", &pid.to_string()])
361                    .output();
362            }
363
364            #[cfg(windows)]
365            {
366                use std::process::Command;
367                // Kill the process on Windows
368                let _ = Command::new("taskkill")
369                    .args(["/PID", &pid.to_string(), "/F"])
370                    .output();
371            }
372
373            // Update metadata
374            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(), // Ignored for local builds
399            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        // Wait a bit for the build to complete
414        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(), // Ignored for local builds
429            script: "exit 1".to_string(),      // This will fail
430            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        // Wait a bit for the build to complete
441        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); // Note: We assume success if process exits cleanly
445        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(), // Ignored for local builds
456            script: "sleep 10".to_string(),    // Long running command
457            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        // Stop the build
468        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}