Skip to main content

xianyun_cluster/
clustertool.rs

1//! Port of `internal/clustertool`: runs interactive external tools against
2//! the Caproni-managed cluster, with a short-lived virtual kubeconfig in
3//! their environment.
4//!
5//! **Driver injection.** The Go original resolves both the configuration
6//! (`config.LoadFromContext(ctx)`) and the cluster manager
7//! (`cluster.NewManager(cfg.Caproni.Cluster, cfg.ProfileName)`) inside
8//! `(*Runner).Run` itself. This port instead takes an already-constructed
9//! `&dyn Driver` and the resolved profile name as explicit parameters,
10//! consistent with the executor-injection pattern used throughout this
11//! crate (see `driver::external`'s module doc comment): the caller (which
12//! already has a `config::Container` from `config::load_from_context` and
13//! a `Driver` from `registry::new_manager`) passes both in, rather than
14//! `Runner` reaching for ambient config/registry state itself. This is a
15//! disclosed signature change from the Go original, not a silent gap — see
16//! `PORT_NOTES.md`.
17//!
18//! **Stream injection.** The Go `Runner` holds `io.Reader`/`io.Writer`
19//! fields so tests can substitute `bytes.Buffer`s for the process's real
20//! stdio. This port's [`Runner`] does the same through [`Runner::new`]
21//! (real stdio, inherited by the child process directly for full
22//! interactivity) and [`Runner::new_captured`] (an in-memory stdin and
23//! captured stdout/stderr, for tests).
24
25use std::io::Write;
26use std::process::{Command, Stdio};
27use std::sync::{Arc, Mutex};
28
29use crate::driver::{Driver, DriverError};
30use crate::kubeconfig::{self, merge::MergeError};
31use thiserror::Error;
32
33/// Port of `clustertool.Tool`.
34#[derive(Debug, Clone, Default)]
35pub struct Tool {
36    /// The executable, resolved from PATH.
37    pub name: String,
38    /// Added to the error when `name` is not on PATH. Empty means no hint.
39    pub install_hint: String,
40    /// Makes `Runner::run` fail before starting the tool when Caproni has no
41    /// controlling terminal, rather than letting a full-screen tool fail on
42    /// its own terms.
43    pub require_terminal: bool,
44}
45
46/// Port of `clustertool.ErrToolNotFound` and `clustertool.ErrNoTerminal`.
47#[derive(Debug, Error, PartialEq, Eq)]
48pub enum ClusterToolError {
49    #[error("tool not found in PATH: {0}")]
50    ToolNotFound(String),
51    #[error("no controlling terminal: {0} needs one")]
52    NoTerminal(String),
53    #[error("{0}")]
54    Other(String),
55}
56
57impl From<DriverError> for ClusterToolError {
58    fn from(e: DriverError) -> Self {
59        ClusterToolError::Other(e.to_string())
60    }
61}
62
63impl From<MergeError> for ClusterToolError {
64    fn from(e: MergeError) -> Self {
65        ClusterToolError::Other(format!("normalizing kubeconfig context: {e}"))
66    }
67}
68
69impl From<kubeconfig::KubeconfigError> for ClusterToolError {
70    fn from(e: kubeconfig::KubeconfigError) -> Self {
71        ClusterToolError::Other(format!("creating virtual kubeconfig: {e}"))
72    }
73}
74
75impl Tool {
76    /// Port of `(Tool).notFoundError`.
77    #[must_use]
78    pub fn not_found_error(&self, err: &str) -> ClusterToolError {
79        if self.install_hint.is_empty() {
80            ClusterToolError::ToolNotFound(format!("{} ({err})", self.name))
81        } else {
82            ClusterToolError::ToolNotFound(format!("{}, {} ({err})", self.name, self.install_hint))
83        }
84    }
85}
86
87/// Where a [`Runner`]'s child process streams go.
88enum Streams {
89    /// The process's own stdio, inherited directly by the child for full
90    /// interactivity (used by [`Runner::new`]).
91    Inherit,
92    /// An in-memory stdin fed to the child, with stdout/stderr captured
93    /// into shared buffers a test can read back afterwards (used by
94    /// [`Runner::new_captured`]).
95    Captured {
96        stdin: Vec<u8>,
97        stdout: Arc<Mutex<Vec<u8>>>,
98        stderr: Arc<Mutex<Vec<u8>>>,
99    },
100}
101
102/// Port of `clustertool.Runner`. The zero value is not usable; use
103/// [`Runner::new`].
104pub struct Runner {
105    streams: Streams,
106    /// Port of `Runner.hasTerminal`.
107    has_terminal: Box<dyn Fn() -> bool + Send + Sync>,
108}
109
110impl Default for Runner {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl Runner {
117    /// Port of `clustertool.NewRunner`: wired to the process's own standard
118    /// streams and terminal.
119    #[must_use]
120    pub fn new() -> Self {
121        Self {
122            streams: Streams::Inherit,
123            has_terminal: Box::new(has_controlling_terminal),
124        }
125    }
126
127    /// Test-only constructor: `stdin_content` is fed to the child's stdin,
128    /// and its stdout/stderr are captured rather than inherited, readable
129    /// afterwards through [`Runner::stdout`]/[`Runner::stderr`].
130    /// `has_terminal` stands in for [`has_controlling_terminal`], so tests
131    /// do not depend on whether the suite happens to run from a terminal.
132    #[must_use]
133    pub fn new_captured(
134        stdin_content: &[u8],
135        has_terminal: impl Fn() -> bool + Send + Sync + 'static,
136    ) -> Self {
137        Self {
138            streams: Streams::Captured {
139                stdin: stdin_content.to_vec(),
140                stdout: Arc::new(Mutex::new(Vec::new())),
141                stderr: Arc::new(Mutex::new(Vec::new())),
142            },
143            has_terminal: Box::new(has_terminal),
144        }
145    }
146
147    /// Returns everything written to stdout so far. Only meaningful for a
148    /// [`Runner::new_captured`] instance; always empty otherwise.
149    #[must_use]
150    pub fn stdout(&self) -> Vec<u8> {
151        match &self.streams {
152            Streams::Captured { stdout, .. } => stdout
153                .lock()
154                .unwrap_or_else(std::sync::PoisonError::into_inner)
155                .clone(),
156            Streams::Inherit => Vec::new(),
157        }
158    }
159
160    /// Returns everything written to stderr so far. Only meaningful for a
161    /// [`Runner::new_captured`] instance; always empty otherwise.
162    #[must_use]
163    pub fn stderr(&self) -> Vec<u8> {
164        match &self.streams {
165            Streams::Captured { stderr, .. } => stderr
166                .lock()
167                .unwrap_or_else(std::sync::PoisonError::into_inner)
168                .clone(),
169            Streams::Inherit => Vec::new(),
170        }
171    }
172
173    /// Port of `(*Runner).Run`. Resolves the cluster kubeconfig from
174    /// `mgr`, writes it to a temporary file, and runs `tool` with `args`
175    /// attached to the caller's streams. Returns the tool's exit status,
176    /// which is only meaningful when the returned error is `Ok`; a tool
177    /// that could not be started at all yields `Err`.
178    ///
179    /// See the module doc comment for why this takes `mgr` and
180    /// `profile_name` directly rather than loading them from an ambient
181    /// config context, as the Go original does.
182    pub fn run(
183        &self,
184        mgr: &dyn Driver,
185        profile_name: &str,
186        tool: &Tool,
187        args: &[String],
188    ) -> Result<i32, ClusterToolError> {
189        let path = look_path(&tool.name).map_err(|e| tool.not_found_error(&e))?;
190
191        if tool.require_terminal && !(self.has_terminal)() {
192            return Err(ClusterToolError::NoTerminal(tool.name.clone()));
193        }
194
195        let kubeconfig_yaml = mgr.get_kubeconfig()?;
196        let kubeconfig_yaml = stable_context(&kubeconfig_yaml, profile_name)?;
197
198        let virtual_kubeconfig = kubeconfig::Config::create(&kubeconfig_yaml)?;
199        let result = self.exec(&path, args, &virtual_kubeconfig.environ());
200        let _ = virtual_kubeconfig.close();
201
202        result
203    }
204
205    /// Port of `(*Runner).exec`: runs the tool on the Runner's streams and
206    /// reports its exit status. A non-zero status is the tool's answer
207    /// rather than a failure, so it is returned with `Ok`; only a tool
208    /// that never ran produces an `Err`.
209    fn exec(
210        &self,
211        path: &std::path::Path,
212        args: &[String],
213        extra_env: &[String],
214    ) -> Result<i32, ClusterToolError> {
215        let mut command = Command::new(path);
216        command.args(args);
217
218        for entry in extra_env {
219            if let Some((key, value)) = entry.split_once('=') {
220                command.env(key, value);
221            }
222        }
223
224        match &self.streams {
225            Streams::Inherit => {
226                command
227                    .stdin(Stdio::inherit())
228                    .stdout(Stdio::inherit())
229                    .stderr(Stdio::inherit());
230
231                let status = command.status().map_err(|e| {
232                    ClusterToolError::Other(format!("running {}: {e}", name_of(path)))
233                })?;
234
235                Ok(status.code().unwrap_or(-1))
236            }
237            Streams::Captured {
238                stdin,
239                stdout,
240                stderr,
241            } => {
242                command
243                    .stdin(Stdio::piped())
244                    .stdout(Stdio::piped())
245                    .stderr(Stdio::piped());
246
247                let mut child = command.spawn().map_err(|e| {
248                    ClusterToolError::Other(format!("running {}: {e}", name_of(path)))
249                })?;
250
251                if let Some(mut child_stdin) = child.stdin.take() {
252                    let _ = child_stdin.write_all(stdin);
253                }
254
255                let output = child.wait_with_output().map_err(|e| {
256                    ClusterToolError::Other(format!("running {}: {e}", name_of(path)))
257                })?;
258
259                *stdout
260                    .lock()
261                    .unwrap_or_else(std::sync::PoisonError::into_inner) = output.stdout;
262                *stderr
263                    .lock()
264                    .unwrap_or_else(std::sync::PoisonError::into_inner) = output.stderr;
265
266                Ok(output.status.code().unwrap_or(-1))
267            }
268        }
269    }
270}
271
272/// Minimal, dependency-free port of Go's `exec.LookPath`: if `name`
273/// contains a path separator it is used directly (after checking it is an
274/// executable file); otherwise every `PATH` entry is searched in order.
275fn look_path(name: &str) -> Result<std::path::PathBuf, String> {
276    if name.contains(std::path::MAIN_SEPARATOR) {
277        let path = std::path::PathBuf::from(name);
278        return if is_executable_file(&path) {
279            Ok(path)
280        } else {
281            Err(format!("{name}: not an executable file"))
282        };
283    }
284
285    let Some(path_var) = std::env::var_os("PATH") else {
286        return Err("PATH is not set".to_string());
287    };
288
289    for dir in std::env::split_paths(&path_var) {
290        let candidate = dir.join(name);
291        if is_executable_file(&candidate) {
292            return Ok(candidate);
293        }
294    }
295
296    Err(format!(
297        "exec: \"{name}\": executable file not found in $PATH"
298    ))
299}
300
301fn is_executable_file(path: &std::path::Path) -> bool {
302    use std::os::unix::fs::PermissionsExt;
303
304    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
305}
306
307fn name_of(path: &std::path::Path) -> String {
308    path.file_name()
309        .map(|n| n.to_string_lossy().into_owned())
310        .unwrap_or_else(|| path.to_string_lossy().into_owned())
311}
312
313/// Port of `clustertool.hasControllingTerminal`: reports whether the
314/// process can open its controlling terminal.
315#[must_use]
316pub fn has_controlling_terminal() -> bool {
317    std::fs::OpenOptions::new()
318        .read(true)
319        .write(true)
320        .open("/dev/tty")
321        .is_ok()
322}
323
324/// Port of `clustertool.stableContext`: renames the cluster kubeconfig's
325/// context to the Caproni context name, so that tools keying persistent
326/// state by context name see the same name whatever cluster driver
327/// produced the kubeconfig.
328pub fn stable_context(
329    kubeconfig_yaml: &str,
330    profile_name: &str,
331) -> Result<String, ClusterToolError> {
332    let normalized = kubeconfig::merge::normalize(
333        kubeconfig_yaml.as_bytes(),
334        &xianyun_core::naming::context_name(profile_name),
335    )?;
336
337    String::from_utf8(normalized)
338        .map_err(|e| ClusterToolError::Other(format!("normalizing kubeconfig context: {e}")))
339}