196 lines
6.6 KiB
Rust
196 lines
6.6 KiB
Rust
//! Internal subprocess helpers: `run`, `run_json`, `run_json_stdin`, and error mappers.
|
|
//!
|
|
//! All provider implementations that shell out to a CLI tool (hcloud, docker, oras)
|
|
//! go through these helpers so error handling is uniform.
|
|
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
use crate::ComputeError;
|
|
|
|
/// Output from a subprocess invocation.
|
|
#[derive(Debug)]
|
|
pub enum CliOutput {
|
|
/// Process exited with a non-zero status.
|
|
NonZero {
|
|
/// Exit code, if available.
|
|
code: Option<i32>,
|
|
/// Captured stderr.
|
|
stderr: String,
|
|
/// Captured stdout (may be partial — retained for debugging).
|
|
#[allow(dead_code)]
|
|
stdout: Vec<u8>,
|
|
},
|
|
/// Process did not complete within the time budget.
|
|
TimedOut(Duration),
|
|
/// I/O error launching or communicating with the process.
|
|
Io(std::io::Error),
|
|
}
|
|
|
|
impl std::fmt::Display for CliOutput {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
CliOutput::NonZero { code, stderr, .. } => {
|
|
write!(f, "exit {:?}: {}", code, stderr.trim())
|
|
}
|
|
CliOutput::TimedOut(d) => write!(f, "timed out after {d:?}"),
|
|
CliOutput::Io(e) => write!(f, "I/O: {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Successful subprocess output (stdout + stderr captured).
|
|
pub struct CliSuccess {
|
|
/// Raw stdout bytes.
|
|
pub stdout: Vec<u8>,
|
|
/// Captured stderr (for debug logging).
|
|
pub stderr: String,
|
|
}
|
|
|
|
/// Run `bin` with `args` within `budget`. Returns stdout on success.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `CliOutput` on non-zero exit, timeout, or I/O failure.
|
|
pub async fn run(
|
|
bin: &Path,
|
|
args: &[String],
|
|
budget: Duration,
|
|
) -> Result<CliSuccess, CliOutput> {
|
|
let child = tokio::process::Command::new(bin)
|
|
.args(args)
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.map_err(CliOutput::Io)?;
|
|
|
|
match tokio::time::timeout(budget, child.wait_with_output()).await {
|
|
Err(_elapsed) => Err(CliOutput::TimedOut(budget)),
|
|
Ok(Err(e)) => Err(CliOutput::Io(e)),
|
|
Ok(Ok(out)) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
if out.status.success() {
|
|
Ok(CliSuccess { stdout: out.stdout, stderr })
|
|
} else {
|
|
Err(CliOutput::NonZero {
|
|
code: out.status.code(),
|
|
stderr,
|
|
stdout: out.stdout,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run `bin` with `args` and deserialize stdout as JSON type `T`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `CliOutput` on non-zero exit, timeout, or I/O failure.
|
|
/// Returns `Err` on JSON parse failure (wrapped as a `CliOutput::NonZero` with stderr set).
|
|
pub async fn run_json<T: serde::de::DeserializeOwned>(
|
|
bin: &Path,
|
|
args: &[String],
|
|
budget: Duration,
|
|
) -> Result<T, CliOutput> {
|
|
let success = run(bin, args, budget).await?;
|
|
tracing::debug!(stderr = %success.stderr, "cli subprocess stderr");
|
|
serde_json::from_slice(&success.stdout).map_err(|e| CliOutput::NonZero {
|
|
code: None,
|
|
stderr: format!("JSON parse failed: {e}"),
|
|
stdout: success.stdout,
|
|
})
|
|
}
|
|
|
|
/// Run `bin` with `args`, writing `stdin_payload` to stdin, then deserialize stdout as `T`.
|
|
///
|
|
/// Used by `ScriptProvider` to send a JSON envelope to a Nu script.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `CliOutput` on non-zero exit, timeout, or I/O failure.
|
|
pub async fn run_json_stdin<T: serde::de::DeserializeOwned>(
|
|
bin: &Path,
|
|
args: &[String],
|
|
stdin_payload: Vec<u8>,
|
|
budget: Duration,
|
|
) -> Result<T, CliOutput> {
|
|
let mut child = tokio::process::Command::new(bin)
|
|
.args(args)
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.map_err(CliOutput::Io)?;
|
|
|
|
// Write stdin then close it so the process sees EOF.
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
stdin.write_all(&stdin_payload).await.map_err(CliOutput::Io)?;
|
|
// Drop closes the handle.
|
|
}
|
|
|
|
match tokio::time::timeout(budget, child.wait_with_output()).await {
|
|
Err(_elapsed) => Err(CliOutput::TimedOut(budget)),
|
|
Ok(Err(e)) => Err(CliOutput::Io(e)),
|
|
Ok(Ok(out)) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
tracing::debug!(stderr = %stderr, "script provider stderr");
|
|
if out.status.success() {
|
|
serde_json::from_slice(&out.stdout).map_err(|e| CliOutput::NonZero {
|
|
code: None,
|
|
stderr: format!("JSON parse failed: {e}"),
|
|
stdout: out.stdout,
|
|
})
|
|
} else {
|
|
Err(CliOutput::NonZero {
|
|
code: out.status.code(),
|
|
stderr,
|
|
stdout: out.stdout,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Error mapping helpers ─────────────────────────────────────────────────────
|
|
|
|
/// Map a `CliOutput` to a `ComputeError` for spawn operations.
|
|
pub fn cli_err_to_compute_err(e: CliOutput) -> ComputeError {
|
|
cli_err_map(e, ComputeError::Spawn)
|
|
}
|
|
|
|
/// Map a `CliOutput` to a `ComputeError` for destroy operations.
|
|
pub fn cli_err_to_compute_err_destroy(e: CliOutput) -> ComputeError {
|
|
cli_err_map(e, ComputeError::Destroy)
|
|
}
|
|
|
|
/// Map a `CliOutput` to a `ComputeError` for status operations.
|
|
pub fn cli_err_to_compute_err_status(e: CliOutput) -> ComputeError {
|
|
cli_err_map(e, ComputeError::Status)
|
|
}
|
|
|
|
/// Map a `CliOutput` to a `ComputeError` for list operations.
|
|
pub fn cli_err_to_compute_err_list(e: CliOutput) -> ComputeError {
|
|
cli_err_map(e, ComputeError::List)
|
|
}
|
|
|
|
fn cli_err_map(e: CliOutput, fallback: impl Fn(String) -> ComputeError) -> ComputeError {
|
|
match e {
|
|
CliOutput::TimedOut(d) => ComputeError::Timeout(d),
|
|
CliOutput::NonZero { ref stderr, .. }
|
|
if stderr.contains("unauthorized") || stderr.contains("403") =>
|
|
{
|
|
ComputeError::Auth(stderr.clone())
|
|
}
|
|
CliOutput::NonZero { ref stderr, .. }
|
|
if stderr.contains("could not connect")
|
|
|| stderr.contains("connection refused")
|
|
|| stderr.contains("no such host") =>
|
|
{
|
|
ComputeError::ProviderUnavailable(stderr.clone())
|
|
}
|
|
CliOutput::NonZero { stderr, .. } => fallback(stderr),
|
|
CliOutput::Io(e) => ComputeError::ProviderUnavailable(e.to_string()),
|
|
}
|
|
}
|