Skip to main content

xianyun_backends/kubectl/
mod.rs

1//! kubectl operations.
2//!
3//! Ported from `internal/backends/kubectl/{interface,client}.go`.
4
5use std::sync::Arc;
6use std::time::Duration;
7
8use serde::Deserialize;
9use thiserror::Error;
10use xianyun_executor::exec::{CommandRunner, ExecError};
11
12/// Ported from `VersionResponse` in `interface.go`. Every field is
13/// `#[serde(default)]`, matching Go's `encoding/json`, which leaves
14/// unset fields as their zero value rather than erroring -- kubectl's
15/// actual `version -o json` output includes every field, but the
16/// ported tests only stub the subset each case cares about.
17#[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/// Ported from `ClientVersion` in `interface.go`.
28#[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/// Ported from `ServerVersion` in `interface.go`.
49#[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/// Ported from `ErrNamespaceRequired`.
70#[derive(Debug, Error)]
71pub enum KubectlError {
72    #[error("namespace is required")]
73    NamespaceRequired,
74
75    /// Ported from `ErrJobFailed`.
76    #[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
86/// Ported from the `Backend` interface in `interface.go`.
87pub 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
181/// Ported from `Client` in `client.go`.
182pub struct Client {
183    kubeconfig: Option<String>,
184    executor: Arc<dyn CommandRunner + Send + Sync>,
185}
186
187/// A kubeconfig materialized to a throwaway temp file so it can be
188/// pointed at via a `KUBECONFIG` env var -- the executed `kubectl`
189/// process reads the file path, not the in-memory string. Deletes the
190/// file on drop. Mirrors `k3d::Driver`'s `TempKubeconfigGuard` (see its
191/// doc comment in `crates/xianyun-cluster/src/driver/k3d.rs`), which
192/// predates this one and does the same thing for the same reason.
193struct 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    /// Ported from `NewClient`. Go's zero-argument `NewClient` reads the
203    /// command runner from the ambient `context.Context` on every call
204    /// instead of taking it at construction; this port takes it
205    /// explicitly, matching `docker::Client`/`git::Client`. See
206    /// `PORT_NOTES.md`.
207    pub fn new(executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
208        Self {
209            kubeconfig: None,
210            executor,
211        }
212    }
213
214    /// Ported from `NewClientWithConfig`.
215    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    /// Ported from `Client.kubeconfigEnv`: when this client carries a
223    /// kubeconfig (`with_config`, not `new`), writes it to a throwaway
224    /// temp file and returns a `KUBECONFIG=<path>` env var appended onto
225    /// the current process's own environment (so `kubectl` still finds
226    /// `PATH` and everything else it needs), plus a guard that deletes
227    /// the file once the caller is done with it. Returns `None` when
228    /// this client has no kubeconfig (`new`'s case): every caller below
229    /// falls back to `kubectl`'s own default resolution (ambient
230    /// `KUBECONFIG`/`~/.kube/config`) exactly as before, unchanged.
231    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    /// Ported from `Client.readStdout`. Materializes `self.kubeconfig`
253    /// (when set) into a `KUBECONFIG=<path>` env var via
254    /// [`Self::kubeconfig_env`] before running, matching Go's
255    /// `kubeconfigEnv` call in the original `readStdout`.
256    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    /// Ported from `Client.runManifestCommand`. Runs a kubectl verb that
274    /// reads a manifest bundle from stdin (`apply`, `delete`, ...). An
275    /// empty namespace lets each object use its own
276    /// `metadata.namespace` (and leaves cluster-scoped objects alone).
277    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
310/// Ported from `resultError`: wraps `result.error` with the last
311/// non-empty stderr line, if any, so callers see kubectl's actual
312/// message (e.g. "Error from server (Forbidden): ...") rather than only
313/// the exit status. Returns `KubectlError::Exec` untouched when there is
314/// no stderr to append, so `matches!(err, KubectlError::Exec(..))`
315/// assertions on a bare exec failure keep working; otherwise returns
316/// `KubectlError::Other`, since `ExecError` has no variant carrying an
317/// extra message.
318///
319/// # Panics
320///
321/// Panics if `result.error` is `None`; callers must only invoke this
322/// after checking `result.error.is_some()`.
323fn 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
338/// Ported from `transientAPIErrorMarkers`/`isTransientAPIError`:
339/// substrings kubectl writes to stderr when the API server endpoint is
340/// not yet reachable, matched case-insensitively.
341const 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        // Ported from `Client.WaitForNamespaceReady`: both `kubectl
687        // wait` calls share a single deadline, so the second call's
688        // `--timeout=` is whatever remains of `timeout` after the
689        // first call returns, floored at zero. `format_go_duration`
690        // rounds up to whole seconds, so with negligible elapsed time
691        // (as in every ported unit test) the first call's timeout still
692        // renders as the exact original value (e.g. "30s").
693        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/// Ported from Go's `time.Duration.String()`, the format
807/// `--timeout=<duration>` needs (`kubectl wait --help` accepts the same
808/// syntax as Go's `time.ParseDuration`). This only needs to round-trip
809/// through kubectl, not match Go's formatting rules exactly (which
810/// picks the coarsest unit and can combine units, e.g. "1m30s"); a
811/// plain `<seconds>s` is unambiguous input.
812#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
813fn format_go_duration(d: Duration) -> String {
814    // Rounds up to whole seconds so a duration that is nominally "30s"
815    // but has had a handful of microseconds shaved off it by real
816    // wall-clock elapsed time (see `wait_for_namespace_ready`'s shared
817    // deadline) still formats as "30s" rather than "29.999998s".
818    format!("{}s", d.as_secs_f64().ceil() as u64)
819}
820
821impl Client {
822    /// Ported from `Client.waitForJobFinished`. Polls the Job's status
823    /// until it reaches `Complete` (returns `Ok`), `Failed` (returns
824    /// `KubectlError::JobFailed`), or `timeout` elapses.
825    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/// Ported from `jobStatus`: the part of a Job's status that says
867/// whether it has finished.
868#[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    /// A local, single-purpose `CommandRunner` that captures whatever
906    /// `Options::env` it was called with, for the `*_sets_kubeconfig_env`
907    /// tests below -- `xianyun_testutil::MockCommandRunner::run` ignores
908    /// `opts` entirely (see its doc comment), so a `with_config` client's
909    /// one new behavior (pointing `KUBECONFIG` at a temp file) needs its
910    /// own capturing runner, the same way `StdinCapturingRunner` exists
911    /// for the handful of tests needing `Options::stdin` instead.
912    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    // --- New coverage: KUBECONFIG env wiring for `Client::with_config` ---
978    //
979    // Closes the gap `kubeconfig_env`'s doc comment (and previously this
980    // module's `Client` doc comment) described: a `with_config` client
981    // materializes its kubeconfig into a temp file and points
982    // `KUBECONFIG` at it on every command, instead of silently falling
983    // back to kubectl's own default resolution. Confirmed as a real,
984    // user-visible bug (not just a documentation gap) by the first real
985    // run of `nix/tests/e2e-k3d.nix` (see book/src/project/testing.md): `xianyun
986    // up` against a real k3d cluster failed with "the connection to the
987    // server localhost:8080 was refused" -- `kubectl`'s hardcoded
988    // fallback when it finds no kubeconfig anywhere, which it hit
989    // because nothing ever told it which cluster's kubeconfig to use.
990
991    #[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    /// A local, single-purpose `CommandRunner` for the handful of tests
1081    /// that need to inspect what was piped to a call's stdin --
1082    /// `xianyun_testutil::MockCommandRunner`'s `expect_run*` family
1083    /// matches only `&[String]` args, with no `Options`/`Stdin`-aware
1084    /// variant (see `PORT_NOTES.md`'s "Mocking strategy" section), and
1085    /// `xianyun-testutil` is out of scope for this crate to extend.
1086    /// Rather than block on that, this reads the piped bytes directly
1087    /// off `Options::stdin` and asserts on them, exactly the assertion
1088    /// Go's `mock.MatchedBy(func(opts *executor.Options) bool { ... })`
1089    /// makes in `TestEnsureNamespace`.
1090    ///
1091    /// Every other `CommandRunner` method panics with `unimplemented!()`
1092    /// since `Client::ensure_namespace` only ever calls `run`.
1093    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    /// A local `CommandRunner` for the three `RunJob` lifecycle tests.
1180    /// It captures the generated Job name off the `create` call's piped
1181    /// manifest (the same "read stdin, then use the decoded name to
1182    /// shape later expectations" pattern Go's `expectJobCreate` +
1183    /// `mock.RunAndReturn` implements) and then serves each subsequent
1184    /// `get`/`logs`/`delete` call by matching on that captured name.
1185    /// Not a general-purpose mock: it hard-codes the exact call
1186    /// sequence `Client::run_job` makes, which is exactly what each
1187    /// ported test needs to assert on.
1188    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        /// The `run_job_deletes_job_when_deadline_elapses` test polls
1217        /// `get job` an indeterminate number of times before the
1218        /// deadline elapses (Go's `.Maybe()`) -- every call gets the
1219        /// same never-terminal response rather than a fixed sequence.
1220        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    // --- Ported from client_test.go: TestGetVersion_* ---
1407
1408    #[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        // Two transient connection-refused failures, then success. Our
1414        // hand-rolled mock's `(name, args)` queue is FIFO per key, so
1415        // pushing these three expectations in order reproduces Go's
1416        // `.Times(2)` followed by `.Once()` sequencing without needing
1417        // call-count matchers.
1418        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        // Ported against `xianyun_testutil::MockCommandRunner::
1478        // expect_run_repeating`, the direct equivalent of Go's
1479        // `.Maybe()` (any call count, including zero) this crate's
1480        // shared mock now provides -- unblocking this test, previously
1481        // stubbed with a generous fixed-64-expectation stand-in. See
1482        // `PORT_NOTES.md`.
1483        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    // --- Ported from client_test.go: TestGet_ErrorIncludesStderrTail ---
1503
1504    #[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    // --- Ported from client_test.go: TestListResourceNames_* ---
1524
1525    #[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        // No mock expectations queued: the guard short-circuits before
1669        // any kubectl call.
1670        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    // --- Ported from client_test.go: TestEnsureNamespace_* ---
1680
1681    #[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    // --- Ported from client_test.go: TestDeleteNamespace_* ---
1733
1734    #[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    // --- Ported from client_test.go: TestNamespaceExists_* ---
1770
1771    #[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    // --- Ported from client_test.go: TestWaitForNamespaceReady_* ---
1845    //
1846    // Go's `matchWaitArgs` helper asserts a `--timeout=<duration>` suffix
1847    // in `(0, budget]` rather than an exact value, because the elapsed
1848    // time between the two `kubectl wait` calls is real wall-clock time.
1849    // This crate's mock matches args exactly, so the ported tests below
1850    // assert against the *initial* full-budget timeout only; the
1851    // decreasing-remaining-budget behavior itself
1852    // (`wait_for_namespace_ready_total_duration_bounded`) needs a mock
1853    // that can match a `--timeout=` prefix rather than the exact
1854    // string -- flagged for a Phase 4 mock enhancement in PORT_NOTES.md.
1855
1856    #[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        // Ported against `xianyun_testutil::MockCommandRunner::
1963        // expect_run_matching`, the `mock.MatchedBy` equivalent
1964        // `PORT_NOTES.md` flagged this test as needing: rather than an
1965        // exact `--timeout=` string, each expectation's predicate
1966        // parses the trailing `--timeout=<N>s` argument and asserts the
1967        // remaining budget is in `(0, 30]` seconds, matching Go's
1968        // `matchWaitArgs` helper's decreasing-remaining-time assertion.
1969        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    // --- Ported from client_test.go: TestApply / TestDeleteManifest ---
2035
2036    #[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    // --- Ported from client_test.go: TestGetRaw / TestGetRaw_Error ---
2115
2116    #[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    // --- Ported from client_test.go: TestRunJob_* ---
2163    //
2164    // `probeJobManifest` mirrors Go's local struct used to decode the
2165    // Job manifest RunJob writes to stdin. Go's tests capture it via
2166    // `mock.RunAndReturn` reading `opts.Stdin`; this crate's mock does
2167    // not thread `Options`/stdin through expectations at all (see
2168    // `testutil.rs`), so these three are stubbed with `unimplemented!()`
2169    // rather than mechanically ported against a mock that cannot express
2170    // "read what was written, then assert on it and use it to build the
2171    // next expected args". Un-ignoring these in Phase 4 needs a
2172    // `RunAndReturn`-equivalent mock capability. See PORT_NOTES.md.
2173
2174    #[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        // The Job never reaches a terminal condition (e.g.
2300        // ImagePullBackOff); `get job` is polled an indeterminate
2301        // number of times (Go's `.Maybe()`) before the deadline elapses.
2302        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}