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            // This detached backend has no output consumer or log retrieval API. Discard
273            // output instead of filling unread pipes or accumulating unbounded log files.
274            .stdout(Stdio::null())
275            .stderr(Stdio::null());
276
277        // Merge build config environment with binding environment variables
278        // Build config environment takes precedence over binding environment
279        let mut merged_environment = self.build_env_vars.clone();
280        merged_environment.extend(config.environment);
281
282        // Add environment variables
283        for (key, value) in &merged_environment {
284            cmd.env(key, value);
285        }
286
287        // Start the process
288        let mut child =
289            cmd.spawn()
290                .into_alien_error()
291                .context(ErrorData::BuildOperationFailed {
292                    binding_name: self.binding_name.clone(),
293                    operation: "start build process".to_string(),
294                })?;
295
296        // Get the PID
297        let pid = child.id().ok_or_else(|| {
298            AlienError::new(ErrorData::BuildOperationFailed {
299                binding_name: self.binding_name.clone(),
300                operation: "get process ID".to_string(),
301            })
302        })?;
303
304        // Create build ID with encoded PID
305        let build_id = Self::encode_build_id(&uuid, pid, timestamp);
306
307        // Save build metadata
308        let metadata = BuildMetadata {
309            uuid: uuid.clone(),
310            pid,
311            start_time: start_time.clone(),
312            end_time: None,
313            status: BuildStatus::Running,
314        };
315        self.save_build_metadata(&uuid, &metadata)?;
316
317        // Detach the child process so it runs independently
318        tokio::spawn(async move {
319            let _ = child.wait().await;
320        });
321
322        Ok(BuildExecution {
323            id: build_id,
324            status: BuildStatus::Running,
325            start_time: Some(start_time),
326            end_time: None,
327        })
328    }
329
330    async fn get_build_status(&self, build_id: &str) -> Result<BuildExecution> {
331        // Decode build ID to get UUID and PID
332        let (uuid, _pid, _timestamp) = Self::decode_build_id(build_id)?;
333
334        // Load build metadata
335        let mut metadata = self.load_build_metadata(&uuid)?;
336
337        // Update status based on current process state
338        self.update_build_status(&mut metadata)?;
339
340        Ok(BuildExecution {
341            id: build_id.to_string(),
342            status: metadata.status,
343            start_time: Some(metadata.start_time),
344            end_time: metadata.end_time,
345        })
346    }
347
348    async fn stop_build(&self, build_id: &str) -> Result<()> {
349        // Decode build ID to get UUID and PID
350        let (uuid, pid, _timestamp) = Self::decode_build_id(build_id)?;
351
352        // Load build metadata
353        let mut metadata = self.load_build_metadata(&uuid)?;
354
355        // Only try to kill if the build is still running
356        if metadata.status == BuildStatus::Running {
357            #[cfg(unix)]
358            {
359                use std::process::Command;
360                // Kill the process
361                let _ = Command::new("kill")
362                    .args(["-TERM", &pid.to_string()])
363                    .output();
364            }
365
366            #[cfg(windows)]
367            {
368                use std::process::Command;
369                // Kill the process on Windows
370                let _ = Command::new("taskkill")
371                    .args(["/PID", &pid.to_string(), "/F"])
372                    .output();
373            }
374
375            // Update metadata
376            metadata.status = BuildStatus::Cancelled;
377            metadata.end_time = Some(chrono::Utc::now().to_rfc3339());
378            self.save_build_metadata(&uuid, &metadata)?;
379        }
380
381        Ok(())
382    }
383}
384
385impl Binding for LocalBuild {}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use std::collections::HashMap;
391    use tempfile::TempDir;
392
393    /// Waits for a build to leave `Running`, or fails saying what it was still doing.
394    ///
395    /// Polls instead of sleeping a fixed time, which breaks under load or alongside the other
396    /// tests in this module.
397    async fn settled_status(local_build: &LocalBuild, id: &str) -> BuildExecution {
398        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
399        loop {
400            let status = local_build.get_build_status(id).await.unwrap();
401            if status.status != BuildStatus::Running {
402                return status;
403            }
404            assert!(
405                std::time::Instant::now() < deadline,
406                "build '{id}' was still Running after 30s"
407            );
408            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
409        }
410    }
411
412    #[tokio::test]
413    async fn test_local_build_success() {
414        let temp_dir = TempDir::new().unwrap();
415        let local_build =
416            LocalBuild::new_from_path("test-build".to_string(), temp_dir.path().to_path_buf());
417
418        let mut config = BuildConfig {
419            image: "ubuntu:20.04".to_string(), // Ignored for local builds
420            script: "echo 'Hello World!'".to_string(),
421            environment: HashMap::new(),
422            timeout_seconds: 30,
423            compute_type: alien_core::ComputeType::Small,
424            monitoring: None,
425        };
426        config
427            .environment
428            .insert("TEST_VAR".to_string(), "test_value".to_string());
429
430        let execution = local_build.start_build(config).await.unwrap();
431        assert!(!execution.id.is_empty());
432        assert_eq!(execution.status, BuildStatus::Running);
433
434        let status = settled_status(&local_build, &execution.id).await;
435        assert_eq!(status.status, BuildStatus::Succeeded);
436        assert!(status.end_time.is_some());
437    }
438
439    #[tokio::test]
440    async fn verbose_build_completes_without_blocking_on_output() {
441        let temp_dir = TempDir::new().unwrap();
442        let local_build =
443            LocalBuild::new_from_path("test-build".to_string(), temp_dir.path().to_path_buf());
444        let config = BuildConfig {
445            image: "unused".to_string(),
446            // Each stream exceeds pipe capacity. The marker proves the script ran past both
447            // writes, rather than relying on the backend's current PID-based success check.
448            script: "printf '%1048576s' x; printf '%1048576s' y >&2; printf done > completed"
449                .to_string(),
450            environment: HashMap::new(),
451            timeout_seconds: 30,
452            compute_type: alien_core::ComputeType::Small,
453            monitoring: None,
454        };
455
456        let execution = local_build.start_build(config).await.unwrap();
457        settled_status(&local_build, &execution.id).await;
458        let (uuid, _, _) = LocalBuild::decode_build_id(&execution.id).unwrap();
459        let build_dir = temp_dir.path().join("builds").join(uuid);
460        assert_eq!(std::fs::read(build_dir.join("completed")).unwrap(), b"done");
461    }
462
463    #[tokio::test]
464    async fn test_local_build_failure() {
465        let temp_dir = TempDir::new().unwrap();
466        let local_build =
467            LocalBuild::new_from_path("test-build".to_string(), temp_dir.path().to_path_buf());
468
469        let config = BuildConfig {
470            image: "ubuntu:20.04".to_string(), // Ignored for local builds
471            script: "exit 1".to_string(),      // This will fail
472            environment: HashMap::new(),
473            timeout_seconds: 30,
474            compute_type: alien_core::ComputeType::Small,
475            monitoring: None,
476        };
477
478        let execution = local_build.start_build(config).await.unwrap();
479        assert!(!execution.id.is_empty());
480        assert_eq!(execution.status, BuildStatus::Running);
481
482        // Succeeded because this backend reports any exit as success — it never reads the exit
483        // status. The script's own failure is not what this pins.
484        let status = settled_status(&local_build, &execution.id).await;
485        assert_eq!(status.status, BuildStatus::Succeeded);
486        assert!(status.end_time.is_some());
487    }
488
489    #[tokio::test]
490    async fn test_local_build_stop() {
491        let temp_dir = TempDir::new().unwrap();
492        let local_build =
493            LocalBuild::new_from_path("test-build".to_string(), temp_dir.path().to_path_buf());
494
495        let config = BuildConfig {
496            image: "ubuntu:20.04".to_string(), // Ignored for local builds
497            script: "sleep 10".to_string(),    // Long running command
498            environment: HashMap::new(),
499            timeout_seconds: 30,
500            compute_type: alien_core::ComputeType::Small,
501            monitoring: None,
502        };
503
504        let execution = local_build.start_build(config).await.unwrap();
505        assert!(!execution.id.is_empty());
506        assert_eq!(execution.status, BuildStatus::Running);
507
508        // Stop the build
509        local_build.stop_build(&execution.id).await.unwrap();
510
511        let status = local_build.get_build_status(&execution.id).await.unwrap();
512        assert_eq!(status.status, BuildStatus::Cancelled);
513        assert!(status.end_time.is_some());
514    }
515
516    #[test]
517    fn test_build_id_encoding_decoding() {
518        let uuid = "550e8400-e29b-41d4-a716-446655440000";
519        let pid = 12345u32;
520        let timestamp = 1234567890u64;
521
522        let build_id = LocalBuild::encode_build_id(uuid, pid, timestamp);
523        let (decoded_uuid, decoded_pid, decoded_timestamp) =
524            LocalBuild::decode_build_id(&build_id).unwrap();
525
526        assert_eq!(decoded_uuid, uuid);
527        assert_eq!(decoded_pid, pid);
528        assert_eq!(decoded_timestamp, timestamp);
529    }
530}