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::null())
275 .stderr(Stdio::null());
276
277 let mut merged_environment = self.build_env_vars.clone();
280 merged_environment.extend(config.environment);
281
282 for (key, value) in &merged_environment {
284 cmd.env(key, value);
285 }
286
287 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 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 let build_id = Self::encode_build_id(&uuid, pid, timestamp);
306
307 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 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 let (uuid, _pid, _timestamp) = Self::decode_build_id(build_id)?;
333
334 let mut metadata = self.load_build_metadata(&uuid)?;
336
337 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 let (uuid, pid, _timestamp) = Self::decode_build_id(build_id)?;
351
352 let mut metadata = self.load_build_metadata(&uuid)?;
354
355 if metadata.status == BuildStatus::Running {
357 #[cfg(unix)]
358 {
359 use std::process::Command;
360 let _ = Command::new("kill")
362 .args(["-TERM", &pid.to_string()])
363 .output();
364 }
365
366 #[cfg(windows)]
367 {
368 use std::process::Command;
369 let _ = Command::new("taskkill")
371 .args(["/PID", &pid.to_string(), "/F"])
372 .output();
373 }
374
375 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 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(), 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 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(), script: "exit 1".to_string(), 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 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(), script: "sleep 10".to_string(), 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 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}