Files
ostiary/src/executor/structured.rs
2026-05-25 20:17:31 +07:00

257 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::error::{AppError, Result};
use serde_json::Value;
#[cfg(unix)]
use libc;
use std::process::Stdio;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::Command,
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
};
#[derive(Debug)]
pub enum StructuredCommand {
Input {
prompt: String,
default: Option<String>,
secret: bool,
},
Menu {
prompt: String,
options: Vec<MenuOption>,
},
Confirm {
prompt: String,
},
Message {
level: MessageLevel,
text: String,
},
Progress {
/// Some(n) = deterministic bar (0-100).
/// None = indeterminate spinner (percent omitted from JSON).
percent: Option<u8>,
message: Option<String>,
},
/// A list of checkboxes and radio buttons.
/// Response: space-separated IDs of all checked/selected fields.
Form {
prompt: String,
fields: Vec<FormField>,
},
/// Run an interactive program that needs the real terminal.
Exec {
shell: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum FormFieldType {
Checkbox,
Radio,
}
#[derive(Debug, Clone)]
pub struct FormField {
pub id: String,
pub label: String,
pub field_type: FormFieldType,
pub default: bool,
/// Radio buttons with the same group are mutually exclusive.
pub group: Option<String>,
}
#[derive(Debug)]
pub struct MenuOption {
pub id: String,
pub label: String,
}
#[derive(Debug)]
pub enum MessageLevel {
Info,
Warn,
Error,
}
/// Запускает bash-скрипт в структурированном режиме.
/// Возвращает:
/// - `output_rx` канал для строк вывода (обычный текст и stderr)
/// - `command_rx` канал для команд от скрипта
/// - `finished_rx` одноразовый канал с кодом завершения
/// - `reply_tx` канал для отправки ответов обратно в stdin скрипта
pub fn spawn(
script: &str,
) -> Result<(
UnboundedReceiver<String>,
UnboundedReceiver<StructuredCommand>,
tokio::sync::oneshot::Receiver<i32>,
UnboundedSender<String>,
tokio::sync::oneshot::Sender<()>, // kill signal
)> {
let mut child = Command::new("bash")
.arg("-c")
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// Capture the PID before moving child into tasks (needed for kill).
let pid = child.id();
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let (output_tx, output_rx) = mpsc::unbounded_channel();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::<String>();
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>();
// Чтение stdout
let output_tx_clone = output_tx.clone();
let cmd_tx_stdout = cmd_tx.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.starts_with("CMD:") {
if parse_command(&line[4..], cmd_tx_stdout.clone()).is_err() {
let _ = output_tx_clone.send(format!("[PROTOCOL ERROR] {}", line));
}
} else {
let _ = output_tx_clone.send(line);
}
}
});
// Чтение stderr
let output_tx_stderr = output_tx.clone();
let cmd_tx_stderr = cmd_tx.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.starts_with("CMD:") {
if parse_command(&line[4..], cmd_tx_stderr.clone()).is_err() {
let _ = output_tx_stderr.send(format!("[PROTOCOL ERROR] {}", line));
}
} else {
let _ = output_tx_stderr.send(format!("stderr: {}", line));
}
}
});
// Запись в stdin
tokio::spawn(async move {
let mut stdin = stdin;
while let Some(response) = reply_rx.recv().await {
if stdin.write_all(response.as_bytes()).await.is_err() {
break;
}
if stdin.write_all(b"\n").await.is_err() {
break;
}
}
});
// Wait for child exit normally.
tokio::spawn(async move {
let status = child.wait().await;
let code = status.map(|s| s.code().unwrap_or(1)).unwrap_or(1);
let _ = finished_tx.send(code);
});
// Kill-watcher: when kill_tx fires, send SIGTERM to the entire process
// group so bash AND all its children (mysqldump, php, etc.) are killed.
tokio::spawn(async move {
if kill_rx.await.is_ok() {
if let Some(pid) = pid {
#[cfg(unix)]
unsafe {
libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
}
}
}
});
Ok((output_rx, cmd_rx, finished_rx, reply_tx, kill_tx))
}
fn parse_command(json_str: &str, cmd_tx: UnboundedSender<StructuredCommand>) -> Result<()> {
let v: Value = serde_json::from_str(json_str)?;
let typ = v["type"].as_str().ok_or_else(|| AppError::Protocol("Missing type".into()))?;
let cmd = match typ {
"input" => {
let prompt = v["prompt"].as_str().unwrap_or("").to_string();
let default = v["default"].as_str().map(String::from);
let secret = v["secret"].as_bool().unwrap_or(false);
StructuredCommand::Input { prompt, default, secret }
}
"menu" => {
let prompt = v["prompt"].as_str().unwrap_or("").to_string();
let options = v["options"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|opt| {
Some(MenuOption {
id: opt["id"].as_str()?.to_string(),
label: opt["label"].as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default();
StructuredCommand::Menu { prompt, options }
}
"confirm" => {
let prompt = v["prompt"].as_str().unwrap_or("").to_string();
StructuredCommand::Confirm { prompt }
}
"message" => {
let level = match v["level"].as_str() {
Some("warn") => MessageLevel::Warn,
Some("error") => MessageLevel::Error,
_ => MessageLevel::Info,
};
let text = v["text"].as_str().unwrap_or("").to_string();
StructuredCommand::Message { level, text }
}
"progress" => {
// percent absent or null → None (indeterminate spinner)
let percent = v["percent"].as_u64().map(|n| n.min(100) as u8);
let message = v["message"].as_str().map(String::from);
StructuredCommand::Progress { percent, message }
}
"form" => {
let prompt = v["prompt"].as_str().unwrap_or("").to_string();
let fields = v["fields"].as_array()
.map(|arr| {
arr.iter().filter_map(|f| {
let id = f["id"].as_str()?.to_string();
let label = f["label"].as_str()?.to_string();
let field_type = match f["field_type"].as_str().unwrap_or("checkbox") {
"radio" => FormFieldType::Radio,
_ => FormFieldType::Checkbox,
};
let default = f["default"].as_bool().unwrap_or(false);
let group = f["group"].as_str().map(String::from);
Some(FormField { id, label, field_type, default, group })
}).collect()
})
.unwrap_or_default();
StructuredCommand::Form { prompt, fields }
}
"exec" => {
let shell = v["shell"].as_str().unwrap_or("").to_string();
StructuredCommand::Exec { shell }
}
_ => return Err(AppError::Protocol(format!("Unknown command type: {}", typ))),
};
cmd_tx.send(cmd).map_err(|_| AppError::Protocol("Command channel closed".into()))?;
Ok(())
}