use anyhow::{Context, Result};
use super::args::RunbookCmd;
use crate::protocol::{Request, Response};
use crate::{client, git, paths, runbooks, servers};
fn read_line() -> Result<String> {
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(line)
}
fn read_hidden() -> Result<String> {
let refuse = || anyhow::anyhow!("this terminal cannot hide what is typed: pass the value with -p NAME=value");
let mut saved: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(0, &mut saved) } != 0 {
return Err(refuse());
}
let mut quiet = saved;
quiet.c_lflag &= !libc::ECHO;
let mut handlers = Vec::new();
for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
let id = unsafe {
signal_hook::low_level::register(sig, move || {
libc::tcsetattr(0, libc::TCSANOW, &saved);
libc::_exit(128 + sig);
})
};
handlers.extend(id.ok());
}
if unsafe { libc::tcsetattr(0, libc::TCSANOW, &quiet) } != 0 {
for id in handlers {
signal_hook::low_level::unregister(id);
}
return Err(refuse());
}
let line = read_line();
unsafe { libc::tcsetattr(0, libc::TCSANOW, &saved) };
for id in handlers {
signal_hook::low_level::unregister(id);
}
eprintln!();
line
}
fn current_repo() -> Option<std::path::PathBuf> {
git::status(&std::env::current_dir().ok()?).map(|g| g.root)
}
fn find_runbook(name: &str) -> Result<runbooks::Runbook> {
let (list, errors) = runbooks::all(current_repo().as_deref());
for e in &errors {
eprintln!("warning: {e}");
}
list.into_iter()
.find(|r| r.name == name)
.with_context(|| format!("no runbook {name:?} (`kraal runbook list`, or `kraal runbook new {name}`)"))
}
pub(super) fn run(action: RunbookCmd, socket: &std::path::Path, own_pane: Option<String>) -> Result<()> {
use std::io::{BufRead, IsTerminal, Write};
match action {
RunbookCmd::List { json } => {
let (list, errors) = runbooks::all(current_repo().as_deref());
for e in &errors {
eprintln!("warning: {e}");
}
if json {
let v: Vec<serde_json::Value> = list
.iter()
.map(|r| {
serde_json::json!({
"name": r.name,
"path": r.path,
"kind": if matches!(r.body, runbooks::Body::Markdown { .. }) { "markdown" } else { "steps" },
"repository": match &r.source { runbooks::Source::Repo(root) => Some(root.clone()), _ => None },
"description": r.description(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&v)?);
return Ok(());
}
if list.is_empty() {
println!("no runbooks yet: `kraal runbook new <name>` (or --markdown)");
}
for r in &list {
let kind = match &r.body {
runbooks::Body::Markdown { .. } => "doc",
runbooks::Body::Steps(s) => match s.on {
runbooks::On::Server => "servers",
runbooks::On::Pane => "pane",
runbooks::On::Local => "local",
},
};
let from = match &r.source {
runbooks::Source::Repo(root) => format!(" [{}]", paths::shorten_home(root)),
runbooks::Source::User => String::new(),
};
println!("{:<24} {:<8} {}{from}", r.name, kind, r.description());
}
Ok(())
}
RunbookCmd::Show { name } => {
let r = find_runbook(&name)?;
println!("{}\n", r.path.display());
print!("{}", r.content);
Ok(())
}
RunbookCmd::New { name, markdown, repo } => {
if !servers::valid_name(&name) {
anyhow::bail!("invalid name {name:?} (letters, digits, - _ .)");
}
let dir = if repo {
runbooks::repo_dir(¤t_repo().context("not in a git repository")?)
} else {
runbooks::user_dir()
};
let path = dir.join(format!("{name}.{}", if markdown { "md" } else { "toml" }));
std::fs::create_dir_all(&dir)?;
let mut file = match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
anyhow::bail!("{} already exists", path.display())
}
other => other?,
};
std::io::Write::write_all(
&mut file,
if markdown { runbooks::TEMPLATE_MD } else { runbooks::TEMPLATE_TOML }.as_bytes(),
)?;
println!("{}", path.display());
Ok(())
}
RunbookCmd::Edit { name } => {
let r = find_runbook(&name)?;
let editor = crate::edit::editor_argv(&r.path, None);
let status = std::process::Command::new(&editor[0]).args(&editor[1..]).status()?;
anyhow::ensure!(status.success(), "the editor failed");
Ok(())
}
RunbookCmd::Run { name, on, params, yes, dry_run, wait } => {
let r = find_runbook(&name)?;
let runbooks::Body::Steps(steps) = &r.body else {
anyhow::bail!("{name} is a document: open it in kraal (ctrl+b R b) and run its steps there");
};
let mut values = Vec::new();
for p in ¶ms {
let (k, v) = p.split_once('=').with_context(|| format!("{p:?}: use NAME=value"))?;
values.push((k.to_string(), v.to_string()));
}
let interactive = std::io::stdin().is_terminal();
for p in &steps.params {
if p.default.is_none() && !values.iter().any(|(k, _)| k == &p.name) && interactive {
eprint!("{}: ", p.prompt());
std::io::stderr().flush()?;
let line = if p.secret { read_hidden()? } else { read_line()? };
values.push((p.name.clone(), line.trim().to_string()));
}
}
let prepare = Request::RunbookPlan { path: r.path.clone(), target: on, params: values, pane: own_pane };
let Response::Plan { text, token } = client::request(socket, prepare)? else {
anyhow::bail!("unexpected answer from the server")
};
println!("{text}");
if dry_run {
return Ok(());
}
if !yes {
anyhow::ensure!(interactive, "add --yes to run it without asking");
eprint!("Run it? [y/N] ");
std::io::stderr().flush()?;
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
if !matches!(line.trim(), "y" | "Y" | "yes") {
anyhow::bail!("cancelled");
}
}
let finished = if wait {
let (tx, rx) = crossbeam_channel::unbounded::<serde_json::Value>();
let (ready_tx, ready_rx) = crossbeam_channel::bounded::<()>(1);
let sock = socket.to_path_buf();
std::thread::spawn(move || {
let _ = client::subscribe(
&sock,
vec!["runbook.finished".into()],
None,
move || {
let _ = ready_tx.send(());
},
|ev| tx.send(ev.clone()).is_ok(),
);
});
ready_rx.recv_timeout(std::time::Duration::from_secs(5)).context("could not follow the run")?;
Some(rx)
} else {
None
};
let Response::Text(id) = client::request(socket, Request::RunbookRun { token })? else {
anyhow::bail!("unexpected answer from the server")
};
println!("started (run {id}); its panes are in the tab \"▶ {name}\"");
let Some(rx) = finished else { return Ok(()) };
let run: u64 = id.parse()?;
for ev in rx {
if ev["run"].as_u64() != Some(run) {
continue;
}
let (ok, failed, skipped) = (&ev["ok"], &ev["failed"], &ev["skipped"]);
println!(
"finished: {ok} ok, {failed} failed, {skipped} skipped · log {}",
ev["log"].as_str().unwrap_or("")
);
anyhow::ensure!(*failed == 0 && *skipped == 0, "{name} did not succeed everywhere");
return Ok(());
}
anyhow::bail!("the server went away before the run finished")
}
}
}