Skip to main content

alien_bindings/providers/build/
kubernetes.rs

1use crate::{
2    error::{Error, ErrorData},
3    traits::{Binding, Build},
4};
5use alien_core::{BuildConfig, BuildExecution, BuildStatus};
6use alien_error::{AlienError, Context};
7use alien_k8s_clients::{
8    kubernetes_client::KubernetesClient, KubernetesClientConfig, KubernetesClientConfigExt as _,
9};
10use async_trait::async_trait;
11use k8s_openapi::api::batch::v1::{Job, JobSpec};
12use k8s_openapi::api::core::v1::{Container, EnvVar, PodSpec, PodTemplateSpec, SecurityContext};
13use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
14use std::collections::BTreeMap;
15use tracing::info;
16use uuid::Uuid;
17
18/// Kubernetes implementation of the `Build` trait.
19///
20/// This implementation creates Kubernetes Jobs to execute build operations
21/// with proper sandboxing and security context.
22#[derive(Debug)]
23pub struct KubernetesBuild {
24    binding_name: String,
25    namespace: String,
26    service_account_name: String,
27    build_env_vars: std::collections::HashMap<String, String>,
28    k8s_client: KubernetesClient,
29}
30
31impl KubernetesBuild {
32    /// Creates a new Kubernetes build instance from binding parameters.
33    pub async fn new(
34        binding_name: String,
35        binding: alien_core::bindings::BuildBinding,
36    ) -> Result<Self, Error> {
37        let (namespace, service_account_name, build_env_vars) =
38            Self::extract_binding_fields(&binding_name, binding)?;
39
40        // Create Kubernetes client from environment
41        let k8s_config = KubernetesClientConfig::from_std_env().await.context(
42            ErrorData::BindingConfigInvalid {
43                binding_name: binding_name.clone(),
44                reason: "Failed to create Kubernetes configuration from environment".to_string(),
45            },
46        )?;
47
48        let k8s_client =
49            KubernetesClient::new(k8s_config)
50                .await
51                .context(ErrorData::BindingConfigInvalid {
52                    binding_name: binding_name.clone(),
53                    reason: "Failed to create Kubernetes client".to_string(),
54                })?;
55
56        Ok(Self {
57            binding_name,
58            namespace,
59            service_account_name,
60            build_env_vars,
61            k8s_client,
62        })
63    }
64
65    fn extract_binding_fields(
66        binding_name: &str,
67        binding: alien_core::bindings::BuildBinding,
68    ) -> Result<(String, String, std::collections::HashMap<String, String>), Error> {
69        let config = match binding {
70            alien_core::bindings::BuildBinding::Kubernetes(config) => config,
71            _ => {
72                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
73                    binding_name: binding_name.to_string(),
74                    reason: "Expected Kubernetes binding, got different service type".to_string(),
75                }));
76            }
77        };
78
79        let namespace = config
80            .namespace
81            .into_value(binding_name, "namespace")
82            .context(ErrorData::BindingConfigInvalid {
83                binding_name: binding_name.to_string(),
84                reason: "Failed to extract namespace from binding".to_string(),
85            })?;
86
87        let service_account_name = config
88            .service_account_name
89            .into_value(binding_name, "service_account_name")
90            .context(ErrorData::BindingConfigInvalid {
91                binding_name: binding_name.to_string(),
92                reason: "Failed to extract service_account_name from binding".to_string(),
93            })?;
94
95        let build_env_vars = config
96            .build_env_vars
97            .into_value(binding_name, "build_env_vars")
98            .context(ErrorData::BindingConfigInvalid {
99                binding_name: binding_name.to_string(),
100                reason: "Failed to extract build_env_vars from binding".to_string(),
101            })?;
102
103        Ok((namespace, service_account_name, build_env_vars))
104    }
105
106    #[cfg(test)]
107    async fn new_for_tests(
108        binding_name: String,
109        binding: alien_core::bindings::BuildBinding,
110    ) -> Result<Self, Error> {
111        let (namespace, service_account_name, build_env_vars) =
112            Self::extract_binding_fields(&binding_name, binding)?;
113
114        let k8s_config = KubernetesClientConfig::Manual {
115            server_url: "https://example.invalid".to_string(),
116            certificate_authority_data: None,
117            insecure_skip_tls_verify: Some(true),
118            client_certificate_data: None,
119            client_key_data: None,
120            token: None,
121            username: None,
122            password: None,
123            namespace: None,
124            additional_headers: std::collections::HashMap::new(),
125        };
126        let k8s_client =
127            KubernetesClient::new(k8s_config)
128                .await
129                .context(ErrorData::BindingConfigInvalid {
130                    binding_name: binding_name.clone(),
131                    reason: "Failed to create Kubernetes client".to_string(),
132                })?;
133
134        Ok(Self {
135            binding_name,
136            namespace,
137            service_account_name,
138            build_env_vars,
139            k8s_client,
140        })
141    }
142
143    /// Creates a Kubernetes Job for build execution
144    fn create_build_job(&self, config: &BuildConfig, build_id: &str) -> Job {
145        // Convert environment variables to Kubernetes format
146        let env_vars: Vec<EnvVar> = self
147            .build_env_vars
148            .iter()
149            .chain(config.environment.iter())
150            .map(|(key, value)| EnvVar {
151                name: key.clone(),
152                value: Some(value.clone()),
153                ..Default::default()
154            })
155            .collect();
156
157        // Create container with security context
158        let container = Container {
159            name: "build".to_string(),
160            image: Some(config.image.clone()),
161            command: Some(vec!["/bin/bash".to_string()]),
162            args: Some(vec!["-c".to_string(), config.script.clone()]),
163            env: Some(env_vars),
164            security_context: Some(SecurityContext {
165                allow_privilege_escalation: Some(false),
166                read_only_root_filesystem: Some(true),
167                run_as_non_root: Some(true),
168                run_as_user: Some(65532),
169                seccomp_profile: Some(k8s_openapi::api::core::v1::SeccompProfile {
170                    type_: "RuntimeDefault".to_string(),
171                    localhost_profile: None,
172                }),
173                ..Default::default()
174            }),
175            ..Default::default()
176        };
177
178        // Create pod template with sandbox labels
179        let pod_template = PodTemplateSpec {
180            metadata: Some(ObjectMeta {
181                labels: Some({
182                    let mut labels = BTreeMap::new();
183                    labels.insert("alien.dev/build-sandbox".to_string(), "true".to_string());
184                    labels.insert("alien.dev/build-id".to_string(), build_id.to_string());
185                    labels.insert(
186                        "app.kubernetes.io/managed-by".to_string(),
187                        "alien".to_string(),
188                    );
189                    labels
190                }),
191                ..Default::default()
192            }),
193            spec: Some(PodSpec {
194                service_account_name: Some(self.service_account_name.clone()),
195                restart_policy: Some("Never".to_string()),
196                automount_service_account_token: Some(false),
197                containers: vec![container],
198                ..Default::default()
199            }),
200        };
201
202        // Create job spec
203        let job_spec = JobSpec {
204            template: pod_template,
205            backoff_limit: Some(0), // Don't retry failed builds
206            active_deadline_seconds: Some(config.timeout_seconds as i64),
207            ..Default::default()
208        };
209
210        // Create job metadata
211        let metadata = ObjectMeta {
212            name: Some(format!("build-{}", build_id)),
213            namespace: Some(self.namespace.clone()),
214            labels: Some({
215                let mut labels = BTreeMap::new();
216                labels.insert("alien.dev/build-id".to_string(), build_id.to_string());
217                labels.insert(
218                    "app.kubernetes.io/managed-by".to_string(),
219                    "alien".to_string(),
220                );
221                labels
222            }),
223            ..Default::default()
224        };
225
226        Job {
227            metadata,
228            spec: Some(job_spec),
229            ..Default::default()
230        }
231    }
232
233    /// Maps Kubernetes job status to Alien build status
234    fn map_job_status_to_build_status(&self, job: &Job) -> BuildStatus {
235        if let Some(status) = &job.status {
236            if let Some(_completion_time) = &status.completion_time {
237                // Job has completed
238                if let Some(succeeded) = status.succeeded {
239                    if succeeded > 0 {
240                        return BuildStatus::Succeeded;
241                    }
242                }
243                if let Some(failed) = status.failed {
244                    if failed > 0 {
245                        return BuildStatus::Failed;
246                    }
247                }
248                // If we have a completion time but no success/failure, it was cancelled
249                return BuildStatus::Cancelled;
250            }
251
252            if let Some(_start_time) = &status.start_time {
253                // Job has started but not completed
254                return BuildStatus::Running;
255            }
256        }
257
258        // Default to queued if we can't determine status
259        BuildStatus::Queued
260    }
261
262    /// Extracts start time from job status
263    fn extract_start_time(&self, job: &Job) -> Option<String> {
264        job.status
265            .as_ref()
266            .and_then(|status| status.start_time.as_ref())
267            .map(|time| time.0.to_rfc3339())
268    }
269
270    /// Extracts end time from job status
271    fn extract_end_time(&self, job: &Job) -> Option<String> {
272        job.status
273            .as_ref()
274            .and_then(|status| status.completion_time.as_ref())
275            .map(|time| time.0.to_rfc3339())
276    }
277}
278
279#[async_trait]
280impl Binding for KubernetesBuild {}
281
282#[async_trait]
283impl Build for KubernetesBuild {
284    async fn start_build(&self, config: BuildConfig) -> crate::error::Result<BuildExecution> {
285        let build_id = Uuid::new_v4().to_string();
286        let start_time = chrono::Utc::now().to_rfc3339();
287
288        info!(
289            binding_name = %self.binding_name,
290            build_id = %build_id,
291            namespace = %self.namespace,
292            "Starting Kubernetes build job"
293        );
294
295        // Create the Kubernetes job
296        let job = self.create_build_job(&config, &build_id);
297
298        // Create the job in Kubernetes
299        let _created_job = self
300            .k8s_client
301            .create_job(&self.namespace, &job)
302            .await
303            .context(ErrorData::BuildOperationFailed {
304                binding_name: self.binding_name.clone(),
305                operation: "create Kubernetes job".to_string(),
306            })?;
307
308        let execution = BuildExecution {
309            id: build_id,
310            status: BuildStatus::Queued,
311            start_time: Some(start_time),
312            end_time: None,
313        };
314
315        info!(
316            binding_name = %self.binding_name,
317            build_id = %execution.id,
318            "Kubernetes build job created successfully"
319        );
320
321        Ok(execution)
322    }
323
324    async fn get_build_status(&self, build_id: &str) -> crate::error::Result<BuildExecution> {
325        info!(
326            binding_name = %self.binding_name,
327            build_id = %build_id,
328            "Getting Kubernetes build job status"
329        );
330
331        let job_name = format!("build-{}", build_id);
332
333        // Get the job from Kubernetes
334        let job = self
335            .k8s_client
336            .get_job(&self.namespace, &job_name)
337            .await
338            .context(ErrorData::BuildOperationFailed {
339                binding_name: self.binding_name.clone(),
340                operation: "get Kubernetes job".to_string(),
341            })?;
342
343        let status = self.map_job_status_to_build_status(&job);
344        let start_time = self.extract_start_time(&job);
345        let end_time = self.extract_end_time(&job);
346
347        let execution = BuildExecution {
348            id: build_id.to_string(),
349            status,
350            start_time,
351            end_time,
352        };
353
354        info!(
355            binding_name = %self.binding_name,
356            build_id = %build_id,
357            status = ?execution.status,
358            "Retrieved Kubernetes build job status"
359        );
360
361        Ok(execution)
362    }
363
364    async fn stop_build(&self, build_id: &str) -> crate::error::Result<()> {
365        info!(
366            binding_name = %self.binding_name,
367            build_id = %build_id,
368            "Stopping Kubernetes build job"
369        );
370
371        let job_name = format!("build-{}", build_id);
372
373        // Delete the job from Kubernetes
374        self.k8s_client
375            .delete_job(&self.namespace, &job_name)
376            .await
377            .context(ErrorData::BuildOperationFailed {
378                binding_name: self.binding_name.clone(),
379                operation: "delete Kubernetes job".to_string(),
380            })?;
381
382        info!(
383            binding_name = %self.binding_name,
384            build_id = %build_id,
385            "Kubernetes build job stopped successfully"
386        );
387
388        Ok(())
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use alien_core::bindings::{BindingValue, BuildBinding};
396    use chrono::TimeZone as _;
397
398    #[tokio::test]
399    async fn test_kubernetes_build_creation() {
400        let binding = BuildBinding::kubernetes(
401            "test-namespace",
402            "test-sa",
403            std::collections::HashMap::new(),
404        );
405
406        let kubernetes_build = KubernetesBuild::new_for_tests("test-binding".to_string(), binding)
407            .await
408            .unwrap();
409
410        assert_eq!(kubernetes_build.namespace, "test-namespace");
411        assert_eq!(kubernetes_build.service_account_name, "test-sa");
412        assert!(kubernetes_build.build_env_vars.is_empty());
413    }
414
415    #[tokio::test]
416    async fn test_create_build_job() {
417        let binding = BuildBinding::kubernetes(
418            "test-namespace",
419            "test-sa",
420            std::collections::HashMap::new(),
421        );
422
423        let kubernetes_build = KubernetesBuild::new_for_tests("test-binding".to_string(), binding)
424            .await
425            .unwrap();
426
427        let config = BuildConfig {
428            image: "ubuntu:20.04".to_string(),
429            script: "echo 'Hello World'".to_string(),
430            environment: std::collections::HashMap::new(),
431            timeout_seconds: 300,
432            compute_type: alien_core::ComputeType::Medium,
433            monitoring: None,
434        };
435
436        let build_id = "test-build-123";
437        let job = kubernetes_build.create_build_job(&config, build_id);
438
439        assert_eq!(job.metadata.name.as_ref().unwrap(), "build-test-build-123");
440        assert_eq!(job.metadata.namespace.as_ref().unwrap(), "test-namespace");
441
442        let container = &job
443            .spec
444            .as_ref()
445            .unwrap()
446            .template
447            .spec
448            .as_ref()
449            .unwrap()
450            .containers[0];
451        assert_eq!(container.name, "build");
452        assert_eq!(container.image.as_ref().unwrap(), "ubuntu:20.04");
453        assert_eq!(
454            container.command.as_ref().unwrap(),
455            &vec!["/bin/bash".to_string()]
456        );
457        assert_eq!(
458            container.args.as_ref().unwrap(),
459            &vec!["-c".to_string(), "echo 'Hello World'".to_string()]
460        );
461
462        let security_context = container.security_context.as_ref().unwrap();
463        assert_eq!(security_context.allow_privilege_escalation, Some(false));
464        assert_eq!(security_context.read_only_root_filesystem, Some(true));
465        assert_eq!(security_context.run_as_non_root, Some(true));
466        assert_eq!(security_context.run_as_user, Some(65532));
467    }
468
469    #[tokio::test]
470    async fn test_map_job_status_to_build_status() {
471        let binding = BuildBinding::kubernetes(
472            "test-namespace",
473            "test-sa",
474            std::collections::HashMap::new(),
475        );
476
477        let kubernetes_build = KubernetesBuild::new_for_tests("test-binding".to_string(), binding)
478            .await
479            .unwrap();
480
481        // Test queued status (no status)
482        let job = Job::default();
483        assert_eq!(
484            kubernetes_build.map_job_status_to_build_status(&job),
485            BuildStatus::Queued
486        );
487
488        // Test running status (has start time, no completion time)
489        let mut job = Job::default();
490        job.status = Some(k8s_openapi::api::batch::v1::JobStatus {
491            start_time: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
492                chrono::Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(),
493            )),
494            ..Default::default()
495        });
496        assert_eq!(
497            kubernetes_build.map_job_status_to_build_status(&job),
498            BuildStatus::Running
499        );
500
501        // Test succeeded status
502        let mut job = Job::default();
503        job.status = Some(k8s_openapi::api::batch::v1::JobStatus {
504            start_time: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
505                chrono::Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(),
506            )),
507            completion_time: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
508                chrono::Utc.with_ymd_and_hms(2023, 1, 1, 1, 0, 0).unwrap(),
509            )),
510            succeeded: Some(1),
511            ..Default::default()
512        });
513        assert_eq!(
514            kubernetes_build.map_job_status_to_build_status(&job),
515            BuildStatus::Succeeded
516        );
517
518        // Test failed status
519        let mut job = Job::default();
520        job.status = Some(k8s_openapi::api::batch::v1::JobStatus {
521            start_time: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
522                chrono::Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(),
523            )),
524            completion_time: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
525                chrono::Utc.with_ymd_and_hms(2023, 1, 1, 1, 0, 0).unwrap(),
526            )),
527            failed: Some(1),
528            ..Default::default()
529        });
530        assert_eq!(
531            kubernetes_build.map_job_status_to_build_status(&job),
532            BuildStatus::Failed
533        );
534    }
535}