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#[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 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 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 fn create_build_job(&self, config: &BuildConfig, build_id: &str) -> Job {
145 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 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 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 let job_spec = JobSpec {
204 template: pod_template,
205 backoff_limit: Some(0), active_deadline_seconds: Some(config.timeout_seconds as i64),
207 ..Default::default()
208 };
209
210 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 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 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 return BuildStatus::Cancelled;
250 }
251
252 if let Some(_start_time) = &status.start_time {
253 return BuildStatus::Running;
255 }
256 }
257
258 BuildStatus::Queued
260 }
261
262 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 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 let job = self.create_build_job(&config, &build_id);
297
298 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 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 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 let job = Job::default();
483 assert_eq!(
484 kubernetes_build.map_job_status_to_build_status(&job),
485 BuildStatus::Queued
486 );
487
488 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 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 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}