provisioning-platform/crates/ontoref-compute/src/cli.rs

197 lines
6.6 KiB
Rust
Raw Normal View History

//! 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()),
}
}