Skip to main content

alien_bindings/providers/build/
local.rs

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