1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
//! `forjar exec` — one command, one machine, no YAML (#446).
//!
//! The ticket's smallest ask: while provisioning a host you need to know
//! something about it RIGHT NOW — `ls -la` on a destination, `id -un`, `df -h`
//! — and writing a `forjar.yaml` task to ask one question is friction that
//! sends the operator back to a raw `ssh` session, outside forjar's transport,
//! its identity and its logging.
//!
//! Everything here goes through [`crate::transport::exec_script`], the same
//! funnel `apply` uses, so `exec` inherits the transport selection (local,
//! SSH, container, pepita) and the I8 bashrs gate rather than re-implementing
//! any of it.
use crate::core::types::{ForjarConfig, Machine};
use std::path::Path;
/// Characters that need no quoting in any POSIX shell.
fn is_bare_safe(c: char) -> bool {
c.is_ascii_alphanumeric() || "._-/=:@,+".contains(c)
}
/// Quote ONE word so the remote shell sees it byte-for-byte.
///
/// The single-quote case is the one that matters: `'` cannot appear inside a
/// single-quoted string, so it is closed, escaped and reopened
/// (`'\''`). `$`, backticks, spaces and newlines are inert inside single
/// quotes, which is why nothing else needs special handling — an argument like
/// `$HOME` is passed as the literal five characters, not expanded here.
pub(crate) fn shell_quote(word: &str) -> String {
if !word.is_empty() && word.chars().all(is_bare_safe) {
return word.to_string();
}
format!("'{}'", word.replace('\'', r"'\''"))
}
/// Join the operator's argv into one command line for the remote shell.
pub(crate) fn shell_join(words: &[String]) -> String {
words
.iter()
.map(|w| shell_quote(w))
.collect::<Vec<_>>()
.join(" ")
}
/// Look a machine up by name, or say what the config actually declares.
///
/// A typo'd machine name is the most common way these verbs are misused, and
/// "machine not found" alone forces the operator to go read the YAML. The file
/// and the inventory are both in the message because both are what they would
/// have looked up.
pub(crate) fn resolve_machine<'a>(
config: &'a ForjarConfig,
name: &str,
file: &Path,
) -> Result<&'a Machine, String> {
config.machines.get(name).ok_or_else(|| {
let known: Vec<&str> = config.machines.keys().map(String::as_str).collect();
format!(
"machine '{name}' is not in {} (machines: {})",
file.display(),
known.join(", ")
)
})
}
/// The hint the ticket asks for by name.
///
/// A `Permission denied` is precisely the failure whose diagnosis needs the
/// owner, the mode and the identity forjar connects as — the three facts
/// `doctor --machine` reports. Pointing at it here is what turns a dead end
/// into a next step.
pub(crate) fn permission_hint(machine: &str, stderr: &str) -> Option<String> {
if !stderr.contains("Permission denied") {
return None;
}
Some(format!(
"hint: forjar doctor --machine {machine} reports the owner and mode of the destination and the identity forjar connects as"
))
}
/// The `--json` record of one run. Every field is a MEASUREMENT.
pub(crate) fn exec_json(machine: &str, out: &crate::transport::ExecOutput) -> String {
serde_json::json!({
"machine": machine,
"exit_code": out.exit_code,
"stdout": out.stdout,
"stderr": out.stderr,
})
.to_string()
}
/// Forward the remote streams to ours, unaltered.
fn forward(machine: &str, out: &crate::transport::ExecOutput, json: bool) {
if json {
println!("{}", exec_json(machine, out));
return;
}
print!("{}", out.stdout);
eprint!("{}", out.stderr);
if let Some(hint) = permission_hint(machine, &out.stderr) {
eprintln!("{hint}");
}
}
/// Leave with the REMOTE process's exit code.
///
/// forjar's own taxonomy ([`crate::core::error::ErrorClass`]) describes
/// forjar's failures; this verb reports someone else's. `exec … -- false` must
/// exit 1 because `false` did, exactly as `ssh host false` does, so the code
/// is written to the process here rather than mapped through a class that
/// would flatten every remote code onto 1.
fn exit_with(code: i32) -> Result<(), String> {
if code == 0 {
return Ok(());
}
use std::io::Write;
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
std::process::exit(code);
}
/// #446: run one command on one machine.
pub(crate) fn cmd_exec(
file: &Path,
machine_name: &str,
command: &[String],
json: bool,
) -> Result<(), String> {
if command.is_empty() {
return Err(
"no command given — use `forjar exec <machine> -- <command> [args...]`".to_string(),
);
}
let config = super::helpers::parse_and_validate(file)?;
let machine = resolve_machine(&config, machine_name, file)?;
let script = shell_join(command);
let out = crate::transport::exec_script(machine, &script)?;
forward(machine_name, &out, json);
exit_with(out.exit_code)
}