Skip to main content

alien_bindings/providers/build/
kubernetes.rs

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