xianyun_cluster/
clustertool.rs1use 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#[derive(Debug, Clone, Default)]
35pub struct Tool {
36 pub name: String,
38 pub install_hint: String,
40 pub require_terminal: bool,
44}
45
46#[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 #[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
87enum Streams {
89 Inherit,
92 Captured {
96 stdin: Vec<u8>,
97 stdout: Arc<Mutex<Vec<u8>>>,
98 stderr: Arc<Mutex<Vec<u8>>>,
99 },
100}
101
102pub struct Runner {
105 streams: Streams,
106 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 #[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 #[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 #[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 #[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 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 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
272fn 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#[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
324pub 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}