1use std::sync::Arc;
6use std::time::Duration;
7
8use serde::Deserialize;
9use thiserror::Error;
10use xianyun_executor::exec::{CommandRunner, ExecError};
11
12#[derive(Debug, Default, Deserialize)]
18pub struct VersionResponse {
19 #[serde(rename = "clientVersion", default)]
20 pub client_version: ClientVersion,
21 #[serde(rename = "kustomizeVersion", default)]
22 pub kustomize_version: String,
23 #[serde(rename = "serverVersion", default)]
24 pub server_version: ServerVersion,
25}
26
27#[derive(Debug, Default, Deserialize)]
29pub struct ClientVersion {
30 #[serde(default)]
31 pub major: String,
32 #[serde(default)]
33 pub minor: String,
34 #[serde(rename = "gitVersion", default)]
35 pub git_version: String,
36 #[serde(rename = "gitCommit", default)]
37 pub git_commit: String,
38 #[serde(rename = "gitTreeState", default)]
39 pub git_tree_state: String,
40 #[serde(rename = "goVersion", default)]
41 pub go_version: String,
42 #[serde(default)]
43 pub compiler: String,
44 #[serde(default)]
45 pub platform: String,
46}
47
48#[derive(Debug, Default, Deserialize)]
50pub struct ServerVersion {
51 #[serde(default)]
52 pub major: String,
53 #[serde(default)]
54 pub minor: String,
55 #[serde(rename = "gitVersion", default)]
56 pub git_version: String,
57 #[serde(rename = "gitCommit", default)]
58 pub git_commit: String,
59 #[serde(rename = "gitTreeState", default)]
60 pub git_tree_state: String,
61 #[serde(rename = "goVersion", default)]
62 pub go_version: String,
63 #[serde(default)]
64 pub compiler: String,
65 #[serde(default)]
66 pub platform: String,
67}
68
69#[derive(Debug, Error)]
71pub enum KubectlError {
72 #[error("namespace is required")]
73 NamespaceRequired,
74
75 #[error("job reached the Failed condition: {0}")]
77 JobFailed(String),
78
79 #[error(transparent)]
80 Exec(#[from] ExecError),
81
82 #[error("{0}")]
83 Other(String),
84}
85
86pub trait Backend {
88 fn get(&self, resource_type: &str, name: &str, namespace: &str)
89 -> Result<String, KubectlError>;
90
91 fn delete_pods_by_label(
92 &self,
93 namespace: &str,
94 label_selector: &str,
95 ) -> Result<usize, KubectlError>;
96
97 fn get_version(
98 &self,
99 timeout: Duration,
100 interval: Duration,
101 ) -> Result<VersionResponse, KubectlError>;
102
103 fn scale(
104 &self,
105 resource_type: &str,
106 name: &str,
107 namespace: &str,
108 replicas: i64,
109 ) -> Result<(), KubectlError>;
110
111 fn apply(&self, json_data: &[u8], namespace: &str) -> Result<(), KubectlError>;
112
113 fn delete_manifest(&self, data: &[u8], namespace: &str) -> Result<(), KubectlError>;
114
115 fn delete(&self, resource_type: &str, name: &str, namespace: &str) -> Result<(), KubectlError>;
116
117 fn wait(
118 &self,
119 resource_type: &str,
120 name: &str,
121 namespace: &str,
122 condition: &str,
123 timeout: Duration,
124 ) -> Result<(), KubectlError>;
125
126 fn wait_for_rollout_status(
127 &self,
128 resource_type: &str,
129 name: &str,
130 namespace: &str,
131 timeout: Duration,
132 ) -> Result<(), KubectlError>;
133
134 fn wait_for_pod_ready_by_label(
135 &self,
136 namespace: &str,
137 label_selector: &str,
138 timeout: Duration,
139 ) -> Result<(), KubectlError>;
140
141 fn wait_for_job_complete(
142 &self,
143 name: &str,
144 namespace: &str,
145 timeout: Duration,
146 ) -> Result<(), KubectlError>;
147
148 fn wait_for_job_created(
149 &self,
150 name: &str,
151 namespace: &str,
152 timeout: Duration,
153 ) -> Result<(), KubectlError>;
154
155 fn list_resource_names(
156 &self,
157 kind: &str,
158 namespace: &str,
159 label_selector: &str,
160 ) -> Result<Vec<String>, KubectlError>;
161
162 fn ensure_namespace(&self, name: &str) -> Result<(), KubectlError>;
163
164 fn delete_namespace(&self, name: &str) -> Result<(), KubectlError>;
165
166 fn namespace_exists(&self, name: &str) -> Result<bool, KubectlError>;
167
168 fn wait_for_namespace_ready(&self, name: &str, timeout: Duration) -> Result<(), KubectlError>;
169
170 fn run_job(
171 &self,
172 image: &str,
173 namespace: &str,
174 timeout: Duration,
175 command: &[&str],
176 ) -> Result<String, KubectlError>;
177
178 fn get_raw(&self, path: &str) -> Result<String, KubectlError>;
179}
180
181pub struct Client {
183 kubeconfig: Option<String>,
184 executor: Arc<dyn CommandRunner + Send + Sync>,
185}
186
187struct KubeconfigFile(std::path::PathBuf);
194
195impl Drop for KubeconfigFile {
196 fn drop(&mut self) {
197 let _ = std::fs::remove_file(&self.0);
198 }
199}
200
201impl Client {
202 pub fn new(executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
208 Self {
209 kubeconfig: None,
210 executor,
211 }
212 }
213
214 pub fn with_config(executor: Arc<dyn CommandRunner + Send + Sync>, kubeconfig: &str) -> Self {
216 Self {
217 kubeconfig: Some(kubeconfig.to_string()),
218 executor,
219 }
220 }
221
222 fn kubeconfig_env(&self) -> Result<Option<(Vec<String>, KubeconfigFile)>, KubectlError> {
232 let Some(kubeconfig) = &self.kubeconfig else {
233 return Ok(None);
234 };
235
236 let pid = std::process::id();
237 let nanos = std::time::SystemTime::now()
238 .duration_since(std::time::UNIX_EPOCH)
239 .map(|d| d.as_nanos())
240 .unwrap_or_default();
241 let path = std::env::temp_dir().join(format!("xianyun-kubeconfig-{pid}-{nanos}"));
242
243 std::fs::write(&path, kubeconfig)
244 .map_err(|err| KubectlError::Other(format!("writing temp kubeconfig: {err}")))?;
245
246 let mut env: Vec<String> = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect();
247 env.push(format!("KUBECONFIG={}", path.display()));
248
249 Ok(Some((env, KubeconfigFile(path))))
250 }
251
252 fn read_stdout(&self, name: &str, args: &[String]) -> Result<String, KubectlError> {
257 let env_guard = self.kubeconfig_env()?;
258 let opts = env_guard
259 .as_ref()
260 .map(|(env, _)| xianyun_executor::exec::Options {
261 env: Some(env.clone()),
262 ..Default::default()
263 });
264
265 let result = self.executor.run(name, args, opts);
266 if result.error.is_some() {
267 return Err(result_error(result));
268 }
269
270 Ok(result.stdout.join("\n"))
271 }
272
273 fn run_manifest_command(
278 &self,
279 verb: &str,
280 data: &[u8],
281 namespace: &str,
282 extra_args: &[&str],
283 ) -> Result<(), KubectlError> {
284 let mut args = vec![verb.to_string()];
285 if !namespace.is_empty() {
286 args.push("-n".to_string());
287 args.push(namespace.to_string());
288 }
289 args.push("-f".to_string());
290 args.push("-".to_string());
291 args.extend(extra_args.iter().map(std::string::ToString::to_string));
292
293 let env_guard = self.kubeconfig_env()?;
294
295 let opts = xianyun_executor::exec::Options {
296 stdin: Some(Box::new(std::io::Cursor::new(data.to_vec()))),
297 env: env_guard.as_ref().map(|(env, _)| env.clone()),
298 ..Default::default()
299 };
300
301 let result = self.executor.run("kubectl", &args, Some(opts));
302 if result.error.is_some() {
303 return Err(result_error(result));
304 }
305
306 Ok(())
307 }
308}
309
310fn result_error(result: xianyun_executor::exec::ExecResult) -> KubectlError {
324 let err = result
325 .error
326 .expect("result_error requires result.error to be Some");
327
328 for line in result.stderr.iter().rev() {
329 let trimmed = line.trim();
330 if !trimmed.is_empty() {
331 return KubectlError::Other(format!("{err}: {trimmed}"));
332 }
333 }
334
335 KubectlError::Exec(err)
336}
337
338const TRANSIENT_API_ERROR_MARKERS: &[&str] = &[
342 "was refused",
343 "connection refused",
344 "unable to connect to the server",
345 "connection reset",
346 "i/o timeout",
347 "tls handshake timeout",
348 "server is currently unable to handle the request",
349];
350
351fn is_transient_api_error(stderr: &[String]) -> bool {
352 let haystack = stderr.join("\n").to_lowercase();
353 TRANSIENT_API_ERROR_MARKERS
354 .iter()
355 .any(|marker| haystack.contains(marker))
356}
357
358impl Backend for Client {
359 fn get(
360 &self,
361 resource_type: &str,
362 name: &str,
363 namespace: &str,
364 ) -> Result<String, KubectlError> {
365 let mut args = vec!["get".to_string(), resource_type.to_string()];
366 if !name.is_empty() {
367 args.push(name.to_string());
368 }
369 args.push("-n".to_string());
370 args.push(namespace.to_string());
371 args.push("-o".to_string());
372 args.push("json".to_string());
373
374 self.read_stdout("kubectl", &args)
375 }
376
377 fn delete_pods_by_label(
378 &self,
379 namespace: &str,
380 label_selector: &str,
381 ) -> Result<usize, KubectlError> {
382 let output = self.read_stdout(
383 "kubectl",
384 &[
385 "get".to_string(),
386 "pods".to_string(),
387 "-n".to_string(),
388 namespace.to_string(),
389 "-l".to_string(),
390 label_selector.to_string(),
391 "-o".to_string(),
392 "jsonpath={.items[*].metadata.name}".to_string(),
393 ],
394 )?;
395
396 let names: Vec<&str> = output.split_whitespace().collect();
397 if names.is_empty() {
398 return Ok(0);
399 }
400
401 let mut args = vec![
402 "delete".to_string(),
403 "pod".to_string(),
404 "-n".to_string(),
405 namespace.to_string(),
406 "--wait=false".to_string(),
407 ];
408 args.extend(names.iter().map(std::string::ToString::to_string));
409
410 self.read_stdout("kubectl", &args)?;
411
412 Ok(names.len())
413 }
414
415 fn get_version(
416 &self,
417 timeout: Duration,
418 interval: Duration,
419 ) -> Result<VersionResponse, KubectlError> {
420 let args = [
421 "version".to_string(),
422 "-o".to_string(),
423 "json".to_string(),
424 "--request-timeout=5s".to_string(),
425 ];
426 let deadline = std::time::Instant::now() + timeout;
427
428 let env_guard = self.kubeconfig_env()?;
429 let env = env_guard.as_ref().map(|(env, _)| env.clone());
430
431 loop {
432 let opts = env.as_ref().map(|env| xianyun_executor::exec::Options {
433 env: Some(env.clone()),
434 ..Default::default()
435 });
436 let result = self.executor.run("kubectl", &args, opts);
437
438 if result.error.is_none() {
439 let joined = result.stdout.join("\n");
440 let response: VersionResponse = serde_json::from_str(&joined).map_err(|err| {
441 KubectlError::Other(format!("parsing kubectl version output: {err}"))
442 })?;
443
444 return Ok(response);
445 }
446
447 if !is_transient_api_error(&result.stderr) {
448 return Err(result_error(result));
449 }
450
451 if std::time::Instant::now() >= deadline {
452 let inner = result_error(result);
453 return Err(KubectlError::Other(format!(
454 "cluster API not ready after {timeout:?}: {inner}"
455 )));
456 }
457
458 std::thread::sleep(interval);
459 }
460 }
461
462 fn scale(
463 &self,
464 resource_type: &str,
465 name: &str,
466 namespace: &str,
467 replicas: i64,
468 ) -> Result<(), KubectlError> {
469 self.read_stdout(
470 "kubectl",
471 &[
472 "scale".to_string(),
473 "--replicas".to_string(),
474 replicas.to_string(),
475 resource_type.to_string(),
476 name.to_string(),
477 "-n".to_string(),
478 namespace.to_string(),
479 ],
480 )?;
481
482 Ok(())
483 }
484
485 fn apply(&self, json_data: &[u8], namespace: &str) -> Result<(), KubectlError> {
486 self.run_manifest_command("apply", json_data, namespace, &[])
487 }
488
489 fn delete_manifest(&self, data: &[u8], namespace: &str) -> Result<(), KubectlError> {
490 self.run_manifest_command("delete", data, namespace, &["--ignore-not-found"])
491 }
492
493 fn delete(&self, resource_type: &str, name: &str, namespace: &str) -> Result<(), KubectlError> {
494 self.read_stdout(
495 "kubectl",
496 &[
497 "delete".to_string(),
498 resource_type.to_string(),
499 name.to_string(),
500 "-n".to_string(),
501 namespace.to_string(),
502 ],
503 )?;
504
505 Ok(())
506 }
507
508 fn wait(
509 &self,
510 resource_type: &str,
511 name: &str,
512 namespace: &str,
513 condition: &str,
514 timeout: Duration,
515 ) -> Result<(), KubectlError> {
516 self.read_stdout(
517 "kubectl",
518 &[
519 "wait".to_string(),
520 format!("--for=condition={condition}"),
521 format!("{resource_type}/{name}"),
522 "-n".to_string(),
523 namespace.to_string(),
524 format!("--timeout={}", format_go_duration(timeout)),
525 ],
526 )?;
527
528 Ok(())
529 }
530
531 fn wait_for_rollout_status(
532 &self,
533 resource_type: &str,
534 name: &str,
535 namespace: &str,
536 timeout: Duration,
537 ) -> Result<(), KubectlError> {
538 self.read_stdout(
539 "kubectl",
540 &[
541 "rollout".to_string(),
542 "status".to_string(),
543 format!("{resource_type}/{name}"),
544 "-n".to_string(),
545 namespace.to_string(),
546 format!("--timeout={}", format_go_duration(timeout)),
547 ],
548 )?;
549
550 Ok(())
551 }
552
553 fn wait_for_pod_ready_by_label(
554 &self,
555 namespace: &str,
556 label_selector: &str,
557 timeout: Duration,
558 ) -> Result<(), KubectlError> {
559 self.read_stdout(
560 "kubectl",
561 &[
562 "wait".to_string(),
563 "--for=condition=Ready".to_string(),
564 "pod".to_string(),
565 "-l".to_string(),
566 label_selector.to_string(),
567 "-n".to_string(),
568 namespace.to_string(),
569 format!("--timeout={}", format_go_duration(timeout)),
570 ],
571 )?;
572
573 Ok(())
574 }
575
576 fn wait_for_job_complete(
577 &self,
578 name: &str,
579 namespace: &str,
580 timeout: Duration,
581 ) -> Result<(), KubectlError> {
582 self.read_stdout(
583 "kubectl",
584 &[
585 "wait".to_string(),
586 "--for=condition=Complete".to_string(),
587 format!("job/{name}"),
588 "-n".to_string(),
589 namespace.to_string(),
590 format!("--timeout={}", format_go_duration(timeout)),
591 ],
592 )?;
593
594 Ok(())
595 }
596
597 fn wait_for_job_created(
598 &self,
599 name: &str,
600 namespace: &str,
601 timeout: Duration,
602 ) -> Result<(), KubectlError> {
603 self.read_stdout(
604 "kubectl",
605 &[
606 "wait".to_string(),
607 "--for=create".to_string(),
608 format!("job/{name}"),
609 "-n".to_string(),
610 namespace.to_string(),
611 format!("--timeout={}", format_go_duration(timeout)),
612 ],
613 )?;
614
615 Ok(())
616 }
617
618 fn list_resource_names(
619 &self,
620 kind: &str,
621 namespace: &str,
622 label_selector: &str,
623 ) -> Result<Vec<String>, KubectlError> {
624 if namespace.is_empty() {
625 return Err(KubectlError::NamespaceRequired);
626 }
627
628 let output = self.read_stdout(
629 "kubectl",
630 &[
631 "get".to_string(),
632 kind.to_string(),
633 "-n".to_string(),
634 namespace.to_string(),
635 "-l".to_string(),
636 label_selector.to_string(),
637 "-o".to_string(),
638 "jsonpath={.items[*].metadata.name}".to_string(),
639 ],
640 )?;
641
642 Ok(output.split_whitespace().map(str::to_string).collect())
643 }
644
645 fn ensure_namespace(&self, name: &str) -> Result<(), KubectlError> {
646 let manifest =
647 format!(r#"{{"apiVersion":"v1","kind":"Namespace","metadata":{{"name":"{name}"}}}}"#);
648
649 self.run_manifest_command("apply", manifest.as_bytes(), "", &[])
650 .map_err(|err| KubectlError::Other(format!("ensuring namespace {name:?}: {err}")))
651 }
652
653 fn delete_namespace(&self, name: &str) -> Result<(), KubectlError> {
654 self.read_stdout(
655 "kubectl",
656 &[
657 "delete".to_string(),
658 "namespace".to_string(),
659 name.to_string(),
660 "--ignore-not-found".to_string(),
661 ],
662 )
663 .map(|_| ())
664 .map_err(|err| KubectlError::Other(format!("deleting namespace {name:?}: {err}")))
665 }
666
667 fn namespace_exists(&self, name: &str) -> Result<bool, KubectlError> {
668 let output = self
669 .read_stdout(
670 "kubectl",
671 &[
672 "get".to_string(),
673 "namespace".to_string(),
674 name.to_string(),
675 "--ignore-not-found".to_string(),
676 "-o".to_string(),
677 "name".to_string(),
678 ],
679 )
680 .map_err(|err| KubectlError::Other(format!("checking namespace {name:?}: {err}")))?;
681
682 Ok(!output.trim().is_empty())
683 }
684
685 fn wait_for_namespace_ready(&self, name: &str, timeout: Duration) -> Result<(), KubectlError> {
686 let deadline = std::time::Instant::now() + timeout;
694 let remaining_timeout_arg = || {
695 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
696 format!("--timeout={}", format_go_duration(remaining))
697 };
698
699 self.read_stdout(
700 "kubectl",
701 &[
702 "wait".to_string(),
703 "--for=jsonpath={.status.phase}=Active".to_string(),
704 format!("namespace/{name}"),
705 remaining_timeout_arg(),
706 ],
707 )
708 .map_err(|err| {
709 KubectlError::Other(format!(
710 "waiting for namespace {name:?} to be Active: {err}"
711 ))
712 })?;
713
714 self.read_stdout(
715 "kubectl",
716 &[
717 "wait".to_string(),
718 "--for=create".to_string(),
719 "serviceaccount/default".to_string(),
720 "-n".to_string(),
721 name.to_string(),
722 remaining_timeout_arg(),
723 ],
724 )
725 .map_err(|err| {
726 KubectlError::Other(format!(
727 "waiting for default ServiceAccount in namespace {name:?}: {err}"
728 ))
729 })?;
730
731 Ok(())
732 }
733
734 fn run_job(
735 &self,
736 image: &str,
737 namespace: &str,
738 timeout: Duration,
739 command: &[&str],
740 ) -> Result<String, KubectlError> {
741 let name = format!(
742 "caproni-probe-{}",
743 std::time::SystemTime::now()
744 .duration_since(std::time::UNIX_EPOCH)
745 .unwrap_or_default()
746 .as_nanos()
747 );
748
749 #[allow(clippy::cast_possible_truncation)]
750 let active_deadline_seconds = timeout.as_secs_f64().ceil().max(1.0) as i64;
751 let command_json = serde_json::to_string(command)
752 .map_err(|err| KubectlError::Other(format!("encoding job {name:?}: {err}")))?;
753 let manifest = format!(
754 r#"{{"apiVersion":"batch/v1","kind":"Job","metadata":{{"name":"{name}","namespace":"{namespace}"}},"spec":{{"backoffLimit":0,"ttlSecondsAfterFinished":60,"activeDeadlineSeconds":{active_deadline_seconds},"template":{{"spec":{{"restartPolicy":"Never","containers":[{{"name":"probe","image":"{image}","command":{command_json}}}]}}}}}}}}"#,
755 );
756
757 self.run_manifest_command("create", manifest.as_bytes(), namespace, &[])
758 .map_err(|err| KubectlError::Other(format!("creating job {name:?}: {err}")))?;
759
760 let cleanup = || {
761 let _ = self.read_stdout(
762 "kubectl",
763 &[
764 "delete".to_string(),
765 "job".to_string(),
766 name.clone(),
767 "-n".to_string(),
768 namespace.to_string(),
769 "--ignore-not-found".to_string(),
770 ],
771 );
772 };
773
774 let wait_result = self.wait_for_job_finished(&name, namespace, timeout);
775 if let Err(err) = wait_result {
776 cleanup();
777 return Err(KubectlError::Other(format!("running job {name:?}: {err}")));
778 }
779
780 let output = self
781 .read_stdout(
782 "kubectl",
783 &[
784 "logs".to_string(),
785 format!("job/{name}"),
786 "-n".to_string(),
787 namespace.to_string(),
788 ],
789 )
790 .map_err(|err| KubectlError::Other(format!("reading logs of job {name:?}: {err}")));
791
792 cleanup();
793
794 output
795 }
796
797 fn get_raw(&self, path: &str) -> Result<String, KubectlError> {
798 self.read_stdout(
799 "kubectl",
800 &["get".to_string(), "--raw".to_string(), path.to_string()],
801 )
802 .map_err(|err| KubectlError::Other(format!("getting raw path {path:?}: {err}")))
803 }
804}
805
806#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
813fn format_go_duration(d: Duration) -> String {
814 format!("{}s", d.as_secs_f64().ceil() as u64)
819}
820
821impl Client {
822 fn wait_for_job_finished(
826 &self,
827 name: &str,
828 namespace: &str,
829 timeout: Duration,
830 ) -> Result<(), KubectlError> {
831 let deadline = std::time::Instant::now() + timeout;
832
833 loop {
834 let out = self.get("job", name, namespace)?;
835 let status: JobStatus = serde_json::from_str(&out)
836 .map_err(|err| KubectlError::Other(format!("parsing job status: {err}")))?;
837
838 for cond in &status.status.conditions {
839 if cond.status != "True" {
840 continue;
841 }
842
843 match cond.type_.as_str() {
844 "Complete" => return Ok(()),
845 "Failed" => {
846 return Err(KubectlError::JobFailed(format!(
847 "{}: {}",
848 cond.reason, cond.message
849 )));
850 }
851 _ => {}
852 }
853 }
854
855 if std::time::Instant::now() >= deadline {
856 return Err(KubectlError::Other(format!(
857 "waiting for job {name} to finish: deadline exceeded"
858 )));
859 }
860
861 std::thread::sleep(Duration::from_millis(50));
862 }
863 }
864}
865
866#[derive(Debug, Deserialize, Default)]
869struct JobStatus {
870 #[serde(default)]
871 status: JobStatusInner,
872}
873
874#[derive(Debug, Deserialize, Default)]
875struct JobStatusInner {
876 #[serde(default)]
877 conditions: Vec<JobCondition>,
878}
879
880#[derive(Debug, Deserialize)]
881struct JobCondition {
882 #[serde(rename = "type")]
883 type_: String,
884 status: String,
885 #[serde(default)]
886 reason: String,
887 #[serde(default)]
888 message: String,
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use std::io::Read as _;
895 use std::path::PathBuf;
896 use std::sync::Mutex;
897
898 use xianyun_executor::exec::{ExecResult, ExecStreamedOpts};
899 use xianyun_testutil::MockCommandRunner;
900
901 fn new_client(mock: MockCommandRunner) -> Client {
902 Client::new(Arc::new(mock))
903 }
904
905 struct EnvCapturingRunner {
913 result: Mutex<Option<ExecResult>>,
914 captured_env: Mutex<Option<Vec<String>>>,
915 }
916
917 impl EnvCapturingRunner {
918 fn new(result: ExecResult) -> Self {
919 Self {
920 result: Mutex::new(Some(result)),
921 captured_env: Mutex::new(None),
922 }
923 }
924 }
925
926 impl CommandRunner for EnvCapturingRunner {
927 fn run(
928 &self,
929 _name: &str,
930 _args: &[String],
931 opts: Option<xianyun_executor::exec::Options>,
932 ) -> ExecResult {
933 *self.captured_env.lock().unwrap() = opts.and_then(|o| o.env);
934 self.result
935 .lock()
936 .unwrap()
937 .take()
938 .expect("EnvCapturingRunner::run invoked more than once")
939 }
940
941 fn read_stdout(&self, _name: &str, _args: &[String]) -> Result<String, ExecError> {
942 unimplemented!("EnvCapturingRunner only supports run()")
943 }
944
945 fn run_sudo(&self, _name: &str, _args: &[String]) -> Result<(), ExecError> {
946 unimplemented!("EnvCapturingRunner only supports run()")
947 }
948
949 fn check(&self, _name: &str, _args: &[String]) -> Result<bool, ExecError> {
950 unimplemented!("EnvCapturingRunner only supports run()")
951 }
952
953 fn exec_streamed_command(
954 &self,
955 _opts: Option<ExecStreamedOpts>,
956 _name: &str,
957 _args: &[String],
958 ) -> Result<(), ExecError> {
959 unimplemented!("EnvCapturingRunner only supports run()")
960 }
961
962 fn look_path(&self, _file: &str) -> Result<PathBuf, ExecError> {
963 unimplemented!("EnvCapturingRunner only supports run()")
964 }
965
966 fn run_in_repository(
967 &self,
968 _repo_dir: &str,
969 _name: &str,
970 _args: &[String],
971 _opts: Option<ExecStreamedOpts>,
972 ) -> Result<(), ExecError> {
973 unimplemented!("EnvCapturingRunner only supports run()")
974 }
975 }
976
977 #[test]
992 fn read_stdout_path_sets_kubeconfig_env_when_with_config() {
993 let runner = Arc::new(EnvCapturingRunner::new(ExecResult {
994 stdout: vec!["ns-a".to_string()],
995 ..Default::default()
996 }));
997 let client = Client::with_config(runner.clone(), "apiVersion: v1\nkind: Config\n");
998
999 let names = client
1000 .list_resource_names("pod", "default", "")
1001 .expect("list_resource_names should succeed");
1002 assert_eq!(names, vec!["ns-a".to_string()]);
1003
1004 let env = runner
1005 .captured_env
1006 .lock()
1007 .unwrap()
1008 .clone()
1009 .expect("with_config client must set Options::env");
1010 assert!(
1011 env.iter().any(|e| e.starts_with("KUBECONFIG=")),
1012 "expected a KUBECONFIG=... entry, got: {env:?}"
1013 );
1014 }
1015
1016 #[test]
1017 fn read_stdout_path_sets_no_env_override_when_plain_new() {
1018 let runner = Arc::new(EnvCapturingRunner::new(ExecResult {
1019 stdout: vec!["ns-a".to_string()],
1020 ..Default::default()
1021 }));
1022 let client = Client::new(runner.clone());
1023
1024 client
1025 .list_resource_names("pod", "default", "")
1026 .expect("list_resource_names should succeed");
1027
1028 assert_eq!(
1029 runner.captured_env.lock().unwrap().clone(),
1030 None,
1031 "a plain `new` client (no kubeconfig) must not override the environment"
1032 );
1033 }
1034
1035 #[test]
1036 fn run_manifest_command_path_sets_kubeconfig_env_when_with_config() {
1037 let runner = Arc::new(EnvCapturingRunner::new(ExecResult::default()));
1038 let client = Client::with_config(runner.clone(), "apiVersion: v1\nkind: Config\n");
1039
1040 client
1041 .apply(b"kind: ConfigMap\n", "default")
1042 .expect("apply should succeed");
1043
1044 let env = runner
1045 .captured_env
1046 .lock()
1047 .unwrap()
1048 .clone()
1049 .expect("with_config client must set Options::env");
1050 assert!(
1051 env.iter().any(|e| e.starts_with("KUBECONFIG=")),
1052 "expected a KUBECONFIG=... entry, got: {env:?}"
1053 );
1054 }
1055
1056 #[test]
1057 fn get_version_path_sets_kubeconfig_env_when_with_config() {
1058 let runner = Arc::new(EnvCapturingRunner::new(ExecResult {
1059 stdout: vec![r#"{"serverVersion":{"gitVersion":"v1.32.5+k3s1"}}"#.to_string()],
1060 ..Default::default()
1061 }));
1062 let client = Client::with_config(runner.clone(), "apiVersion: v1\nkind: Config\n");
1063
1064 client
1065 .get_version(Duration::from_secs(1), Duration::from_millis(10))
1066 .expect("get_version should succeed");
1067
1068 let env = runner
1069 .captured_env
1070 .lock()
1071 .unwrap()
1072 .clone()
1073 .expect("with_config client must set Options::env");
1074 assert!(
1075 env.iter().any(|e| e.starts_with("KUBECONFIG=")),
1076 "expected a KUBECONFIG=... entry, got: {env:?}"
1077 );
1078 }
1079
1080 struct StdinCapturingRunner {
1094 expected_name: String,
1095 expected_args: Vec<String>,
1096 expected_stdin: String,
1097 result: Mutex<Option<ExecResult>>,
1098 }
1099
1100 impl StdinCapturingRunner {
1101 fn new(name: &str, args: &[&str], expected_stdin: &str, result: ExecResult) -> Self {
1102 Self {
1103 expected_name: name.to_string(),
1104 expected_args: owned_strings(args),
1105 expected_stdin: expected_stdin.to_string(),
1106 result: Mutex::new(Some(result)),
1107 }
1108 }
1109 }
1110
1111 fn owned_strings(args: &[&str]) -> Vec<String> {
1112 args.iter().map(|s| (*s).to_string()).collect()
1113 }
1114
1115 impl CommandRunner for StdinCapturingRunner {
1116 fn run(
1117 &self,
1118 name: &str,
1119 args: &[String],
1120 opts: Option<xianyun_executor::exec::Options>,
1121 ) -> ExecResult {
1122 assert_eq!(name, self.expected_name);
1123 assert_eq!(args, self.expected_args.as_slice());
1124
1125 let mut opts = opts.expect("caller must pipe the manifest through Options::stdin");
1126 let mut stdin = opts
1127 .stdin
1128 .take()
1129 .expect("caller must set Options::stdin to the manifest bytes");
1130 let mut buf = String::new();
1131 stdin
1132 .read_to_string(&mut buf)
1133 .expect("piped stdin must be valid UTF-8");
1134 assert_eq!(buf, self.expected_stdin);
1135
1136 self.result
1137 .lock()
1138 .unwrap()
1139 .take()
1140 .expect("StdinCapturingRunner::run invoked more than once")
1141 }
1142
1143 fn read_stdout(&self, _name: &str, _args: &[String]) -> Result<String, ExecError> {
1144 unimplemented!("StdinCapturingRunner only supports run()")
1145 }
1146
1147 fn run_sudo(&self, _name: &str, _args: &[String]) -> Result<(), ExecError> {
1148 unimplemented!("StdinCapturingRunner only supports run()")
1149 }
1150
1151 fn check(&self, _name: &str, _args: &[String]) -> Result<bool, ExecError> {
1152 unimplemented!("StdinCapturingRunner only supports run()")
1153 }
1154
1155 fn exec_streamed_command(
1156 &self,
1157 _opts: Option<ExecStreamedOpts>,
1158 _name: &str,
1159 _args: &[String],
1160 ) -> Result<(), ExecError> {
1161 unimplemented!("StdinCapturingRunner only supports run()")
1162 }
1163
1164 fn look_path(&self, _file: &str) -> Result<PathBuf, ExecError> {
1165 unimplemented!("StdinCapturingRunner only supports run()")
1166 }
1167
1168 fn run_in_repository(
1169 &self,
1170 _repo_dir: &str,
1171 _name: &str,
1172 _args: &[String],
1173 _opts: Option<ExecStreamedOpts>,
1174 ) -> Result<(), ExecError> {
1175 unimplemented!("StdinCapturingRunner only supports run()")
1176 }
1177 }
1178
1179 struct JobLifecycleRunner {
1189 namespace: String,
1190 job_name: Mutex<Option<String>>,
1191 get_job_responses: Mutex<Vec<String>>,
1192 get_job_repeat_last: bool,
1193 logs_stdout: Option<Vec<String>>,
1194 delete_calls: Mutex<u32>,
1195 }
1196
1197 impl JobLifecycleRunner {
1198 fn new(
1199 namespace: &str,
1200 get_job_responses: Vec<&str>,
1201 logs_stdout: Option<Vec<&str>>,
1202 ) -> Self {
1203 Self {
1204 namespace: namespace.to_string(),
1205 job_name: Mutex::new(None),
1206 get_job_responses: Mutex::new(
1207 get_job_responses.into_iter().map(str::to_string).collect(),
1208 ),
1209 get_job_repeat_last: false,
1210 logs_stdout: logs_stdout
1211 .map(|lines| lines.into_iter().map(str::to_string).collect()),
1212 delete_calls: Mutex::new(0),
1213 }
1214 }
1215
1216 fn repeating(namespace: &str, get_job_response: &str) -> Self {
1221 Self {
1222 namespace: namespace.to_string(),
1223 job_name: Mutex::new(None),
1224 get_job_responses: Mutex::new(vec![get_job_response.to_string()]),
1225 get_job_repeat_last: true,
1226 logs_stdout: None,
1227 delete_calls: Mutex::new(0),
1228 }
1229 }
1230
1231 fn job_name(&self) -> String {
1232 self.job_name
1233 .lock()
1234 .unwrap()
1235 .clone()
1236 .expect("job must have been created before this assertion")
1237 }
1238
1239 fn delete_call_count(&self) -> u32 {
1240 *self.delete_calls.lock().unwrap()
1241 }
1242 }
1243
1244 impl CommandRunner for JobLifecycleRunner {
1245 fn run(
1246 &self,
1247 name: &str,
1248 args: &[String],
1249 opts: Option<xianyun_executor::exec::Options>,
1250 ) -> ExecResult {
1251 assert_eq!(name, "kubectl");
1252 let verb = args.first().map(String::as_str);
1253
1254 match verb {
1255 Some("create") => {
1256 assert_eq!(
1257 args,
1258 ["create", "-n", self.namespace.as_str(), "-f", "-"].as_slice()
1259 );
1260
1261 let mut opts =
1262 opts.expect("RunJob must pipe the Job manifest through Options::stdin");
1263 let mut stdin = opts
1264 .stdin
1265 .take()
1266 .expect("RunJob must set Options::stdin to the Job manifest");
1267 let mut data = Vec::new();
1268 stdin
1269 .read_to_end(&mut data)
1270 .expect("piped Job manifest must be readable");
1271
1272 let manifest: serde_json::Value =
1273 serde_json::from_slice(&data).expect("piped Job manifest must be JSON");
1274 let job_name = manifest["metadata"]["name"]
1275 .as_str()
1276 .expect("Job manifest must set metadata.name")
1277 .to_string();
1278
1279 assert_eq!(manifest["apiVersion"], "batch/v1");
1280 assert_eq!(manifest["kind"], "Job");
1281 assert_eq!(manifest["metadata"]["namespace"], self.namespace);
1282 assert_eq!(manifest["spec"]["backoffLimit"], 0);
1283 assert!(
1284 manifest["spec"]["ttlSecondsAfterFinished"]
1285 .as_i64()
1286 .unwrap()
1287 > 0
1288 );
1289 assert_eq!(
1290 manifest["spec"]["template"]["spec"]["restartPolicy"],
1291 "Never"
1292 );
1293
1294 *self.job_name.lock().unwrap() = Some(job_name);
1295 ExecResult::default()
1296 }
1297 Some("get") => {
1298 let job_name = self.job_name();
1299 assert_eq!(
1300 args,
1301 [
1302 "get",
1303 "job",
1304 job_name.as_str(),
1305 "-n",
1306 self.namespace.as_str(),
1307 "-o",
1308 "json",
1309 ]
1310 .as_slice()
1311 );
1312
1313 let mut queue = self.get_job_responses.lock().unwrap();
1314 let json = if self.get_job_repeat_last {
1315 queue[0].clone()
1316 } else {
1317 assert!(!queue.is_empty(), "unexpected extra `kubectl get job` call");
1318 queue.remove(0)
1319 };
1320 drop(queue);
1321
1322 ExecResult {
1323 stdout: vec![json],
1324 ..Default::default()
1325 }
1326 }
1327 Some("logs") => {
1328 let job_name = self.job_name();
1329 assert_eq!(
1330 args,
1331 [
1332 "logs",
1333 format!("job/{job_name}").as_str(),
1334 "-n",
1335 self.namespace.as_str(),
1336 ]
1337 .as_slice()
1338 );
1339
1340 ExecResult {
1341 stdout: self
1342 .logs_stdout
1343 .clone()
1344 .expect("this scenario does not expect a `kubectl logs` call"),
1345 ..Default::default()
1346 }
1347 }
1348 Some("delete") => {
1349 let job_name = self.job_name();
1350 assert_eq!(
1351 args,
1352 [
1353 "delete",
1354 "job",
1355 job_name.as_str(),
1356 "-n",
1357 self.namespace.as_str(),
1358 "--ignore-not-found",
1359 ]
1360 .as_slice()
1361 );
1362
1363 *self.delete_calls.lock().unwrap() += 1;
1364 ExecResult::default()
1365 }
1366 other => panic!("JobLifecycleRunner: unexpected kubectl verb {other:?}"),
1367 }
1368 }
1369
1370 fn read_stdout(&self, _name: &str, _args: &[String]) -> Result<String, ExecError> {
1371 unimplemented!("JobLifecycleRunner only supports run()")
1372 }
1373
1374 fn run_sudo(&self, _name: &str, _args: &[String]) -> Result<(), ExecError> {
1375 unimplemented!("JobLifecycleRunner only supports run()")
1376 }
1377
1378 fn check(&self, _name: &str, _args: &[String]) -> Result<bool, ExecError> {
1379 unimplemented!("JobLifecycleRunner only supports run()")
1380 }
1381
1382 fn exec_streamed_command(
1383 &self,
1384 _opts: Option<ExecStreamedOpts>,
1385 _name: &str,
1386 _args: &[String],
1387 ) -> Result<(), ExecError> {
1388 unimplemented!("JobLifecycleRunner only supports run()")
1389 }
1390
1391 fn look_path(&self, _file: &str) -> Result<PathBuf, ExecError> {
1392 unimplemented!("JobLifecycleRunner only supports run()")
1393 }
1394
1395 fn run_in_repository(
1396 &self,
1397 _repo_dir: &str,
1398 _name: &str,
1399 _args: &[String],
1400 _opts: Option<ExecStreamedOpts>,
1401 ) -> Result<(), ExecError> {
1402 unimplemented!("JobLifecycleRunner only supports run()")
1403 }
1404 }
1405
1406 #[test]
1409 fn get_version_retries_transient_errors_until_reachable() {
1410 let mock = MockCommandRunner::new();
1411 let args = ["version", "-o", "json", "--request-timeout=5s"];
1412
1413 for _ in 0..2 {
1419 mock.expect_run(
1420 "kubectl",
1421 &args,
1422 ExecResult {
1423 error: Some(ExecError::RunFailed(
1424 "The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?".to_string(),
1425 )),
1426 stderr: vec![
1427 "The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?"
1428 .to_string(),
1429 ],
1430 ..Default::default()
1431 },
1432 );
1433 }
1434
1435 mock.expect_run(
1436 "kubectl",
1437 &args,
1438 ExecResult {
1439 stdout: vec![r#"{"serverVersion":{"gitVersion":"v1.99.0+k3s1"}}"#.to_string()],
1440 ..Default::default()
1441 },
1442 );
1443
1444 let client = new_client(mock);
1445
1446 let got = client
1447 .get_version(Duration::from_secs(1), Duration::from_millis(1))
1448 .unwrap();
1449 assert_eq!(got.server_version.git_version, "v1.99.0+k3s1");
1450 }
1451
1452 #[test]
1453 fn get_version_returns_permanent_errors_immediately() {
1454 let mock = MockCommandRunner::new();
1455 mock.expect_run(
1456 "kubectl",
1457 &["version", "-o", "json", "--request-timeout=5s"],
1458 ExecResult {
1459 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1460 stderr: vec![
1461 "error: You must be logged in to the server (Unauthorized)".to_string(),
1462 ],
1463 ..Default::default()
1464 },
1465 );
1466
1467 let client = new_client(mock);
1468
1469 let err = client
1470 .get_version(Duration::from_secs(60), Duration::from_millis(1))
1471 .unwrap_err();
1472 assert!(err.to_string().contains("Unauthorized"));
1473 }
1474
1475 #[test]
1476 fn get_version_times_out_while_api_stays_unreachable() {
1477 let mock = MockCommandRunner::new();
1484 let args = ["version", "-o", "json", "--request-timeout=5s"];
1485
1486 mock.expect_run_repeating("kubectl", &args, || ExecResult {
1487 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1488 stderr: vec!["The connection to the server 127.0.0.1:6443 was refused".to_string()],
1489 ..Default::default()
1490 });
1491
1492 let client = new_client(mock);
1493
1494 let err = client
1495 .get_version(Duration::from_millis(20), Duration::from_millis(1))
1496 .unwrap_err();
1497 let msg = err.to_string();
1498 assert!(msg.contains("not ready"));
1499 assert!(msg.contains("refused"));
1500 }
1501
1502 #[test]
1505 fn get_error_includes_stderr_tail() {
1506 let mock = MockCommandRunner::new();
1507 mock.expect_run(
1508 "kubectl",
1509 &["get", "configmap", "-n", "kube-system", "-o", "json"],
1510 ExecResult {
1511 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1512 stderr: vec![r#"Error from server (Forbidden): configmaps is forbidden: User "dev" cannot list resource "configmaps""#.to_string()],
1513 ..Default::default()
1514 },
1515 );
1516
1517 let client = new_client(mock);
1518
1519 let err = client.get("configmap", "", "kube-system").unwrap_err();
1520 assert!(err.to_string().contains("Forbidden"));
1521 }
1522
1523 #[test]
1526 fn list_resource_names_empty() {
1527 let mock = MockCommandRunner::new();
1528 mock.expect_run(
1529 "kubectl",
1530 &[
1531 "get",
1532 "deployment",
1533 "-n",
1534 "cert-manager",
1535 "-l",
1536 "app.kubernetes.io/instance=cert-manager",
1537 "-o",
1538 "jsonpath={.items[*].metadata.name}",
1539 ],
1540 ExecResult {
1541 stdout: vec![String::new()],
1542 ..Default::default()
1543 },
1544 );
1545
1546 let client = new_client(mock);
1547
1548 let names = client
1549 .list_resource_names(
1550 "deployment",
1551 "cert-manager",
1552 "app.kubernetes.io/instance=cert-manager",
1553 )
1554 .unwrap();
1555 assert!(names.is_empty());
1556 }
1557
1558 #[test]
1559 fn list_resource_names_single() {
1560 let mock = MockCommandRunner::new();
1561 mock.expect_run(
1562 "kubectl",
1563 &[
1564 "get",
1565 "deployment",
1566 "-n",
1567 "cnpg-system",
1568 "-l",
1569 "app.kubernetes.io/instance=cloudnative-pg",
1570 "-o",
1571 "jsonpath={.items[*].metadata.name}",
1572 ],
1573 ExecResult {
1574 stdout: vec!["cloudnative-pg".to_string()],
1575 ..Default::default()
1576 },
1577 );
1578
1579 let client = new_client(mock);
1580
1581 let names = client
1582 .list_resource_names(
1583 "deployment",
1584 "cnpg-system",
1585 "app.kubernetes.io/instance=cloudnative-pg",
1586 )
1587 .unwrap();
1588 assert_eq!(names, vec!["cloudnative-pg".to_string()]);
1589 }
1590
1591 #[test]
1592 fn list_resource_names_multiple() {
1593 let mock = MockCommandRunner::new();
1594 mock.expect_run(
1595 "kubectl",
1596 &[
1597 "get",
1598 "deployment",
1599 "-n",
1600 "cert-manager",
1601 "-l",
1602 "app.kubernetes.io/instance=cert-manager",
1603 "-o",
1604 "jsonpath={.items[*].metadata.name}",
1605 ],
1606 ExecResult {
1607 stdout: vec![
1608 "cert-manager cert-manager-cainjector cert-manager-webhook".to_string(),
1609 ],
1610 ..Default::default()
1611 },
1612 );
1613
1614 let client = new_client(mock);
1615
1616 let names = client
1617 .list_resource_names(
1618 "deployment",
1619 "cert-manager",
1620 "app.kubernetes.io/instance=cert-manager",
1621 )
1622 .unwrap();
1623 assert_eq!(
1624 names,
1625 vec![
1626 "cert-manager".to_string(),
1627 "cert-manager-cainjector".to_string(),
1628 "cert-manager-webhook".to_string(),
1629 ]
1630 );
1631 }
1632
1633 #[test]
1634 fn list_resource_names_error() {
1635 let mock = MockCommandRunner::new();
1636 mock.expect_run(
1637 "kubectl",
1638 &[
1639 "get",
1640 "deployment",
1641 "-n",
1642 "cnpg-system",
1643 "-l",
1644 "app.kubernetes.io/instance=cloudnative-pg",
1645 "-o",
1646 "jsonpath={.items[*].metadata.name}",
1647 ],
1648 ExecResult {
1649 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1650 ..Default::default()
1651 },
1652 );
1653
1654 let client = new_client(mock);
1655
1656 let err = client
1657 .list_resource_names(
1658 "deployment",
1659 "cnpg-system",
1660 "app.kubernetes.io/instance=cloudnative-pg",
1661 )
1662 .unwrap_err();
1663 assert!(matches!(err, KubectlError::Exec(_)));
1664 }
1665
1666 #[test]
1667 fn list_resource_names_empty_namespace() {
1668 let mock = MockCommandRunner::new();
1671 let client = new_client(mock);
1672
1673 let err = client
1674 .list_resource_names("deployment", "", "app.kubernetes.io/instance=cert-manager")
1675 .unwrap_err();
1676 assert!(matches!(err, KubectlError::NamespaceRequired));
1677 }
1678
1679 #[test]
1682 fn ensure_namespace() {
1683 let runner = StdinCapturingRunner::new(
1684 "kubectl",
1685 &["apply", "-f", "-"],
1686 r#"{"apiVersion":"v1","kind":"Namespace","metadata":{"name":"gitlab"}}"#,
1687 ExecResult::default(),
1688 );
1689
1690 let client = Client::new(Arc::new(runner));
1691
1692 client.ensure_namespace("gitlab").unwrap();
1693 }
1694
1695 #[test]
1696 fn ensure_namespace_error() {
1697 let mock = MockCommandRunner::new();
1698 mock.expect_run(
1699 "kubectl",
1700 &["apply", "-f", "-"],
1701 ExecResult {
1702 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1703 ..Default::default()
1704 },
1705 );
1706
1707 let client = new_client(mock);
1708
1709 let err = client.ensure_namespace("gitlab").unwrap_err();
1710 assert!(err.to_string().contains("gitlab"));
1711 }
1712
1713 #[test]
1714 fn ensure_namespace_error_includes_stderr_tail() {
1715 let mock = MockCommandRunner::new();
1716 mock.expect_run(
1717 "kubectl",
1718 &["apply", "-f", "-"],
1719 ExecResult {
1720 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1721 stderr: vec![r#"Error from server (Forbidden): namespaces is forbidden: User "dev" cannot create resource "namespaces""#.to_string()],
1722 ..Default::default()
1723 },
1724 );
1725
1726 let client = new_client(mock);
1727
1728 let err = client.ensure_namespace("gitlab").unwrap_err();
1729 assert!(err.to_string().contains("Forbidden"));
1730 }
1731
1732 #[test]
1735 fn delete_namespace() {
1736 let mock = MockCommandRunner::new();
1737 mock.expect_run(
1738 "kubectl",
1739 &["delete", "namespace", "gitlab", "--ignore-not-found"],
1740 ExecResult {
1741 stdout: vec![String::new()],
1742 ..Default::default()
1743 },
1744 );
1745
1746 let client = new_client(mock);
1747
1748 client.delete_namespace("gitlab").unwrap();
1749 }
1750
1751 #[test]
1752 fn delete_namespace_error() {
1753 let mock = MockCommandRunner::new();
1754 mock.expect_run(
1755 "kubectl",
1756 &["delete", "namespace", "gitlab", "--ignore-not-found"],
1757 ExecResult {
1758 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1759 ..Default::default()
1760 },
1761 );
1762
1763 let client = new_client(mock);
1764
1765 let err = client.delete_namespace("gitlab").unwrap_err();
1766 assert!(err.to_string().contains("gitlab"));
1767 }
1768
1769 #[test]
1772 fn namespace_exists_true() {
1773 let mock = MockCommandRunner::new();
1774 mock.expect_run(
1775 "kubectl",
1776 &[
1777 "get",
1778 "namespace",
1779 "gitlab",
1780 "--ignore-not-found",
1781 "-o",
1782 "name",
1783 ],
1784 ExecResult {
1785 stdout: vec!["namespace/gitlab".to_string()],
1786 ..Default::default()
1787 },
1788 );
1789
1790 let client = new_client(mock);
1791
1792 assert!(client.namespace_exists("gitlab").unwrap());
1793 }
1794
1795 #[test]
1796 fn namespace_exists_not_found() {
1797 let mock = MockCommandRunner::new();
1798 mock.expect_run(
1799 "kubectl",
1800 &[
1801 "get",
1802 "namespace",
1803 "gitlab",
1804 "--ignore-not-found",
1805 "-o",
1806 "name",
1807 ],
1808 ExecResult {
1809 stdout: vec![String::new()],
1810 ..Default::default()
1811 },
1812 );
1813
1814 let client = new_client(mock);
1815
1816 assert!(!client.namespace_exists("gitlab").unwrap());
1817 }
1818
1819 #[test]
1820 fn namespace_exists_error() {
1821 let mock = MockCommandRunner::new();
1822 mock.expect_run(
1823 "kubectl",
1824 &[
1825 "get",
1826 "namespace",
1827 "gitlab",
1828 "--ignore-not-found",
1829 "-o",
1830 "name",
1831 ],
1832 ExecResult {
1833 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1834 ..Default::default()
1835 },
1836 );
1837
1838 let client = new_client(mock);
1839
1840 let err = client.namespace_exists("gitlab").unwrap_err();
1841 assert!(err.to_string().contains("gitlab"));
1842 }
1843
1844 #[test]
1857 fn wait_for_namespace_ready() {
1858 let mock = MockCommandRunner::new();
1859 mock.expect_run(
1860 "kubectl",
1861 &[
1862 "wait",
1863 "--for=jsonpath={.status.phase}=Active",
1864 "namespace/gitlab",
1865 "--timeout=30s",
1866 ],
1867 ExecResult {
1868 stdout: vec!["namespace/gitlab condition met".to_string()],
1869 ..Default::default()
1870 },
1871 );
1872 mock.expect_run(
1873 "kubectl",
1874 &[
1875 "wait",
1876 "--for=create",
1877 "serviceaccount/default",
1878 "-n",
1879 "gitlab",
1880 "--timeout=30s",
1881 ],
1882 ExecResult {
1883 stdout: vec!["serviceaccount/default condition met".to_string()],
1884 ..Default::default()
1885 },
1886 );
1887
1888 let client = new_client(mock);
1889
1890 client
1891 .wait_for_namespace_ready("gitlab", Duration::from_secs(30))
1892 .unwrap();
1893 }
1894
1895 #[test]
1896 fn wait_for_namespace_ready_phase_error() {
1897 let mock = MockCommandRunner::new();
1898 mock.expect_run(
1899 "kubectl",
1900 &[
1901 "wait",
1902 "--for=jsonpath={.status.phase}=Active",
1903 "namespace/gitlab",
1904 "--timeout=30s",
1905 ],
1906 ExecResult {
1907 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1908 ..Default::default()
1909 },
1910 );
1911
1912 let client = new_client(mock);
1913
1914 let err = client
1915 .wait_for_namespace_ready("gitlab", Duration::from_secs(30))
1916 .unwrap_err();
1917 assert!(err.to_string().contains("gitlab"));
1918 }
1919
1920 #[test]
1921 fn wait_for_namespace_ready_service_account_error() {
1922 let mock = MockCommandRunner::new();
1923 mock.expect_run(
1924 "kubectl",
1925 &[
1926 "wait",
1927 "--for=jsonpath={.status.phase}=Active",
1928 "namespace/gitlab",
1929 "--timeout=30s",
1930 ],
1931 ExecResult {
1932 stdout: vec!["namespace/gitlab condition met".to_string()],
1933 ..Default::default()
1934 },
1935 );
1936 mock.expect_run(
1937 "kubectl",
1938 &[
1939 "wait",
1940 "--for=create",
1941 "serviceaccount/default",
1942 "-n",
1943 "gitlab",
1944 "--timeout=30s",
1945 ],
1946 ExecResult {
1947 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
1948 ..Default::default()
1949 },
1950 );
1951
1952 let client = new_client(mock);
1953
1954 let err = client
1955 .wait_for_namespace_ready("gitlab", Duration::from_secs(30))
1956 .unwrap_err();
1957 assert!(err.to_string().contains("gitlab"));
1958 }
1959
1960 #[test]
1961 fn wait_for_namespace_ready_total_duration_bounded() {
1962 fn matches_wait_args(prefix: &[&str], budget_secs: u64) -> impl Fn(&[String]) -> bool {
1970 let prefix: Vec<String> = prefix.iter().map(|s| s.to_string()).collect();
1971 move |args: &[String]| {
1972 if args.len() != prefix.len() + 1 {
1973 return false;
1974 }
1975 if args[..prefix.len()] != prefix[..] {
1976 return false;
1977 }
1978
1979 let Some(timeout_str) = args[prefix.len()]
1980 .strip_prefix("--timeout=")
1981 .and_then(|s| s.strip_suffix('s'))
1982 else {
1983 return false;
1984 };
1985 let Ok(remaining) = timeout_str.parse::<f64>() else {
1986 return false;
1987 };
1988
1989 remaining > 0.0 && remaining <= budget_secs as f64
1990 }
1991 }
1992
1993 let mock = MockCommandRunner::new();
1994 mock.expect_run_matching(
1995 "kubectl",
1996 matches_wait_args(
1997 &[
1998 "wait",
1999 "--for=jsonpath={.status.phase}=Active",
2000 "namespace/gitlab",
2001 ],
2002 30,
2003 ),
2004 ExecResult {
2005 stdout: vec!["namespace/gitlab condition met".to_string()],
2006 ..Default::default()
2007 },
2008 );
2009 mock.expect_run_matching(
2010 "kubectl",
2011 matches_wait_args(
2012 &[
2013 "wait",
2014 "--for=create",
2015 "serviceaccount/default",
2016 "-n",
2017 "gitlab",
2018 ],
2019 30,
2020 ),
2021 ExecResult {
2022 stdout: vec!["serviceaccount/default condition met".to_string()],
2023 ..Default::default()
2024 },
2025 );
2026
2027 let client = new_client(mock);
2028
2029 client
2030 .wait_for_namespace_ready("gitlab", Duration::from_secs(30))
2031 .unwrap();
2032 }
2033
2034 #[test]
2037 fn apply_with_namespace() {
2038 let mock = MockCommandRunner::new();
2039 mock.expect_run(
2040 "kubectl",
2041 &["apply", "-n", "gitlab", "-f", "-"],
2042 ExecResult::default(),
2043 );
2044
2045 let client = new_client(mock);
2046
2047 client
2048 .apply(b"apiVersion: v1\nkind: ConfigMap\n", "gitlab")
2049 .unwrap();
2050 }
2051
2052 #[test]
2053 fn apply_without_namespace_uses_per_object_metadata() {
2054 let mock = MockCommandRunner::new();
2055 mock.expect_run("kubectl", &["apply", "-f", "-"], ExecResult::default());
2056
2057 let client = new_client(mock);
2058
2059 client
2060 .apply(b"apiVersion: v1\nkind: ConfigMap\n", "")
2061 .unwrap();
2062 }
2063
2064 #[test]
2065 fn delete_manifest_with_namespace() {
2066 let mock = MockCommandRunner::new();
2067 mock.expect_run(
2068 "kubectl",
2069 &["delete", "-n", "gitlab", "-f", "-", "--ignore-not-found"],
2070 ExecResult::default(),
2071 );
2072
2073 let client = new_client(mock);
2074
2075 client
2076 .delete_manifest(b"apiVersion: v1\nkind: ConfigMap\n", "gitlab")
2077 .unwrap();
2078 }
2079
2080 #[test]
2081 fn delete_manifest_without_namespace_uses_per_object_metadata() {
2082 let mock = MockCommandRunner::new();
2083 mock.expect_run(
2084 "kubectl",
2085 &["delete", "-f", "-", "--ignore-not-found"],
2086 ExecResult::default(),
2087 );
2088
2089 let client = new_client(mock);
2090
2091 client
2092 .delete_manifest(b"apiVersion: v1\nkind: ConfigMap\n", "")
2093 .unwrap();
2094 }
2095
2096 #[test]
2097 fn delete_manifest_error() {
2098 let mock = MockCommandRunner::new();
2099 mock.expect_run(
2100 "kubectl",
2101 &["delete", "-n", "gitlab", "-f", "-", "--ignore-not-found"],
2102 ExecResult {
2103 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
2104 ..Default::default()
2105 },
2106 );
2107
2108 let client = new_client(mock);
2109
2110 let err = client.delete_manifest(b"invalid", "gitlab").unwrap_err();
2111 assert!(matches!(err, KubectlError::Exec(_)));
2112 }
2113
2114 #[test]
2117 fn get_raw() {
2118 let mock = MockCommandRunner::new();
2119 mock.expect_run(
2120 "kubectl",
2121 &["get", "--raw", "/api/v1/nodes/node-a/proxy/configz"],
2122 ExecResult {
2123 stdout: vec![r#"{"kubeletconfig":{"clusterDomain":"cluster.local"}}"#.to_string()],
2124 ..Default::default()
2125 },
2126 );
2127
2128 let client = new_client(mock);
2129
2130 let out = client
2131 .get_raw("/api/v1/nodes/node-a/proxy/configz")
2132 .unwrap();
2133 let got: serde_json::Value = serde_json::from_str(&out).unwrap();
2134 let want: serde_json::Value =
2135 serde_json::from_str(r#"{"kubeletconfig":{"clusterDomain":"cluster.local"}}"#).unwrap();
2136 assert_eq!(got, want);
2137 }
2138
2139 #[test]
2140 fn get_raw_error() {
2141 let mock = MockCommandRunner::new();
2142 mock.expect_run(
2143 "kubectl",
2144 &["get", "--raw", "/api/v1/nodes/node-a/proxy/configz"],
2145 ExecResult {
2146 error: Some(ExecError::RunFailed("kubectl failed".to_string())),
2147 stderr: vec![r#"Error from server (Forbidden): nodes "node-a" is forbidden: User "dev" cannot get resource "nodes/proxy""#.to_string()],
2148 ..Default::default()
2149 },
2150 );
2151
2152 let client = new_client(mock);
2153
2154 let err = client
2155 .get_raw("/api/v1/nodes/node-a/proxy/configz")
2156 .unwrap_err();
2157 let msg = err.to_string();
2158 assert!(msg.contains("/api/v1/nodes/node-a/proxy/configz"));
2159 assert!(msg.contains("Forbidden"));
2160 }
2161
2162 #[derive(Debug, Deserialize)]
2175 #[allow(dead_code)]
2176 struct ProbeJobManifest {
2177 #[serde(rename = "apiVersion")]
2178 api_version: String,
2179 kind: String,
2180 metadata: ProbeJobMetadata,
2181 spec: ProbeJobSpec,
2182 }
2183
2184 #[derive(Debug, Deserialize)]
2185 #[allow(dead_code)]
2186 struct ProbeJobMetadata {
2187 name: String,
2188 namespace: String,
2189 }
2190
2191 #[derive(Debug, Deserialize)]
2192 #[allow(dead_code)]
2193 struct ProbeJobSpec {
2194 #[serde(rename = "backoffLimit")]
2195 backoff_limit: Option<i64>,
2196 #[serde(rename = "ttlSecondsAfterFinished")]
2197 ttl_seconds_after_finished: Option<i64>,
2198 #[serde(rename = "activeDeadlineSeconds")]
2199 active_deadline_seconds: i64,
2200 template: ProbeJobTemplate,
2201 }
2202
2203 #[derive(Debug, Deserialize)]
2204 #[allow(dead_code)]
2205 struct ProbeJobTemplate {
2206 spec: ProbeJobTemplateSpec,
2207 }
2208
2209 #[derive(Debug, Deserialize)]
2210 #[allow(dead_code)]
2211 struct ProbeJobTemplateSpec {
2212 #[serde(rename = "restartPolicy")]
2213 restart_policy: String,
2214 containers: Vec<ProbeJobContainer>,
2215 }
2216
2217 #[derive(Debug, Deserialize)]
2218 #[allow(dead_code)]
2219 struct ProbeJobContainer {
2220 image: String,
2221 command: Vec<String>,
2222 }
2223
2224 #[test]
2225 fn run_job_returns_logs_and_deletes_job() {
2226 let runner = JobLifecycleRunner::new(
2227 "default",
2228 vec![r#"{"status":{"conditions":[{"type":"Complete","status":"True"}]}}"#],
2229 Some(vec![
2230 "search default.svc.cluster.local svc.cluster.local cluster.local",
2231 "nameserver 10.43.0.10",
2232 ]),
2233 );
2234
2235 let runner = Arc::new(runner);
2236 let client = Client::new(runner.clone());
2237
2238 let out = client
2239 .run_job(
2240 "busybox:1.36",
2241 "default",
2242 Duration::from_secs(60),
2243 &["cat", "/etc/resolv.conf"],
2244 )
2245 .unwrap();
2246
2247 assert_eq!(
2248 out,
2249 "search default.svc.cluster.local svc.cluster.local cluster.local\nnameserver 10.43.0.10"
2250 );
2251 assert_eq!(
2252 runner.delete_call_count(),
2253 1,
2254 "the probe Job must be deleted"
2255 );
2256 }
2257
2258 #[test]
2259 fn run_job_failed_job_reports_reason_without_waiting_for_timeout() {
2260 let runner = JobLifecycleRunner::new(
2261 "default",
2262 vec![
2263 r#"{"status":{"conditions":[{"type":"Failed","status":"True","reason":"BackoffLimitExceeded","message":"Job has reached the specified backoff limit"}]}}"#,
2264 ],
2265 None,
2266 );
2267
2268 let start = std::time::Instant::now();
2269 let runner = Arc::new(runner);
2270 let client = Client::new(runner.clone());
2271
2272 let err = client
2273 .run_job(
2274 "busybox:1.36",
2275 "default",
2276 Duration::from_secs(3600),
2277 &["cat", "/etc/resolv.conf"],
2278 )
2279 .unwrap_err();
2280
2281 assert!(err.to_string().contains("BackoffLimitExceeded"));
2282 assert!(
2283 err.to_string()
2284 .contains("Job has reached the specified backoff limit")
2285 );
2286 assert!(
2287 start.elapsed() < Duration::from_secs(10),
2288 "a failed Job must be reported as soon as it is observed"
2289 );
2290 assert_eq!(
2291 runner.delete_call_count(),
2292 1,
2293 "the probe Job must be deleted"
2294 );
2295 }
2296
2297 #[test]
2298 fn run_job_deletes_job_when_deadline_elapses() {
2299 let runner = JobLifecycleRunner::repeating("default", r#"{"status":{}}"#);
2303 let runner = Arc::new(runner);
2304 let client = Client::new(runner.clone());
2305
2306 let err = client
2307 .run_job(
2308 "busybox:1.36",
2309 "default",
2310 Duration::from_millis(20),
2311 &["cat", "/etc/resolv.conf"],
2312 )
2313 .unwrap_err();
2314
2315 assert!(err.to_string().contains("deadline exceeded"));
2316 assert_eq!(
2317 runner.delete_call_count(),
2318 1,
2319 "cleanup must still run when the deadline elapses"
2320 );
2321 }
2322}