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#[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 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 fn create_build_job(&self, config: &BuildConfig, build_id: &str) -> Job {
152 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 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 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 let job_spec = JobSpec {
211 template: pod_template,
212 backoff_limit: Some(0), active_deadline_seconds: Some(config.timeout_seconds as i64),
214 ..Default::default()
215 };
216
217 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 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 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 return BuildStatus::Cancelled;
257 }
258
259 if let Some(_start_time) = &status.start_time {
260 return BuildStatus::Running;
262 }
263 }
264
265 BuildStatus::Queued
267 }
268
269 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 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 let job = self.create_build_job(&config, &build_id);
304
305 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 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 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 let job = Job::default();
490 assert_eq!(
491 kubernetes_build.map_job_status_to_build_status(&job),
492 BuildStatus::Queued
493 );
494
495 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 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 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}