mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 16:01:07 +00:00
404 lines
12 KiB
Rust
404 lines
12 KiB
Rust
use serde::Serialize;
|
|
|
|
use crate::client::endpoint::{EndpointCatalog, ProfileId};
|
|
|
|
const HELP: &str = "Usage:
|
|
herdr machine list [--json]
|
|
herdr machine add <ssh-target> --label <label> [--remote-session <name>]
|
|
herdr machine rename <profile-id> --label <label>
|
|
herdr machine remove <profile-id>
|
|
herdr machine enable <profile-id>
|
|
herdr machine disable <profile-id>
|
|
|
|
Add prepares the remote Herdr installation and starts its server before saving.
|
|
Missing or incompatible installations require approval in an interactive terminal.
|
|
Changes apply automatically to open local Herdr clients.
|
|
Removing or disabling a machine leaves its remote sessions running.
|
|
Saved machines contain only a label, SSH target, explicit Herdr session, and enabled state.
|
|
SSH credentials and key material remain owned by OpenSSH.";
|
|
|
|
#[derive(Serialize)]
|
|
struct MachineListRow<'a> {
|
|
id: &'a str,
|
|
label: &'a str,
|
|
target: &'a str,
|
|
session: &'a str,
|
|
enabled: bool,
|
|
selected: bool,
|
|
}
|
|
|
|
pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> {
|
|
match args.first().map(String::as_str) {
|
|
Some("list") => list(&args[1..]),
|
|
Some("add") => add(&args[1..]),
|
|
Some("rename") => rename(&args[1..]),
|
|
Some("remove") => remove(&args[1..]),
|
|
Some("enable") => set_enabled(&args[1..], true),
|
|
Some("disable") => set_enabled(&args[1..], false),
|
|
Some("help" | "--help" | "-h") => {
|
|
println!("{HELP}");
|
|
Ok(0)
|
|
}
|
|
_ => {
|
|
eprintln!("{HELP}");
|
|
Ok(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn list(args: &[String]) -> std::io::Result<i32> {
|
|
let json = match args {
|
|
[] => false,
|
|
[flag] if flag == "--json" => true,
|
|
_ => {
|
|
eprintln!("usage: herdr machine list [--json]");
|
|
return Ok(2);
|
|
}
|
|
};
|
|
let catalog = load_catalog()?;
|
|
let rows = catalog
|
|
.ssh
|
|
.iter()
|
|
.map(|profile| MachineListRow {
|
|
id: profile.id.as_str(),
|
|
label: &profile.label,
|
|
target: &profile.target,
|
|
session: &profile.session,
|
|
enabled: profile.enabled,
|
|
selected: catalog.selected_profile.as_ref() == Some(&profile.id),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&rows).map_err(std::io::Error::other)?
|
|
);
|
|
return Ok(0);
|
|
}
|
|
if rows.is_empty() {
|
|
println!("No saved SSH machines.");
|
|
return Ok(0);
|
|
}
|
|
for row in rows {
|
|
let state = if row.enabled { "enabled" } else { "disabled" };
|
|
println!(
|
|
"{}\t{}\t{}\t{}\t{}",
|
|
row.id, row.label, row.target, row.session, state
|
|
);
|
|
}
|
|
Ok(0)
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct AddArgs {
|
|
target: String,
|
|
label: String,
|
|
session: String,
|
|
}
|
|
|
|
fn parse_add_args(args: &[String]) -> Result<AddArgs, String> {
|
|
let args = super::expand_equals_args(args, &["--label", "--remote-session"]);
|
|
let mut target = None;
|
|
let mut label = None;
|
|
let mut session = None;
|
|
let mut index = 0;
|
|
while index < args.len() {
|
|
let (name, value) = match args[index].as_str() {
|
|
"--label" | "--remote-session" => {
|
|
let Some(value) = args.get(index + 1) else {
|
|
return Err(format!("missing value for {}", args[index]));
|
|
};
|
|
index += 2;
|
|
(args[index - 2].as_str(), value.clone())
|
|
}
|
|
positional if !positional.starts_with('-') && target.is_none() => {
|
|
target = Some(positional.to_owned());
|
|
index += 1;
|
|
continue;
|
|
}
|
|
unknown => {
|
|
return Err(format!("unknown machine add option: {unknown}"));
|
|
}
|
|
};
|
|
match name {
|
|
"--label" if label.is_none() => label = Some(value),
|
|
"--remote-session" if session.is_none() => session = Some(value),
|
|
"--remote-session" => {
|
|
return Err("--remote-session can only be specified once".into());
|
|
}
|
|
"--label" => {
|
|
return Err("--label can only be specified once".into());
|
|
}
|
|
_ => unreachable!("validated machine add option"),
|
|
}
|
|
}
|
|
let target = target.ok_or_else(|| {
|
|
"usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]".to_owned()
|
|
})?;
|
|
let label = label.ok_or_else(|| "--label is required".to_owned())?;
|
|
let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned());
|
|
Ok(AddArgs {
|
|
target,
|
|
label,
|
|
session,
|
|
})
|
|
}
|
|
|
|
fn add(args: &[String]) -> std::io::Result<i32> {
|
|
let AddArgs {
|
|
target,
|
|
label,
|
|
session,
|
|
} = match parse_add_args(args) {
|
|
Ok(args) => args,
|
|
Err(error) => {
|
|
eprintln!("{error}");
|
|
return Ok(2);
|
|
}
|
|
};
|
|
let mut catalog = load_catalog()?;
|
|
match catalog.add_ssh(label.clone(), &target, session.clone()) {
|
|
Ok(_) => {}
|
|
Err(error) => {
|
|
eprintln!("error: {error}");
|
|
return Ok(2);
|
|
}
|
|
}
|
|
let metadata = match crate::remote::prepare_saved_ssh(&target, &session) {
|
|
Ok(metadata) => metadata,
|
|
Err(error) => {
|
|
eprintln!("error: {error}; machine was not saved");
|
|
crate::remote::print_saved_ssh_error_hint(&error, &target);
|
|
return Ok(1);
|
|
}
|
|
};
|
|
// Setup can wait for human approval. Do not overwrite catalog edits made meanwhile.
|
|
let mut catalog = load_catalog().map_err(|error| {
|
|
std::io::Error::other(format!(
|
|
"remote prepared, but machine was not saved: {error}"
|
|
))
|
|
})?;
|
|
let id = match catalog.add_ssh(label, &target, &session) {
|
|
Ok(id) => id,
|
|
Err(error) => {
|
|
eprintln!("error: {error}");
|
|
return Ok(2);
|
|
}
|
|
};
|
|
store_catalog(&catalog).map_err(|error| {
|
|
std::io::Error::other(format!(
|
|
"remote prepared, but machine was not saved: {error}"
|
|
))
|
|
})?;
|
|
if let Some(metadata) = metadata {
|
|
crate::client::endpoint::SshMetadataCache::new(id.as_str(), &target, &session)?
|
|
.store(&metadata);
|
|
}
|
|
println!("Saved SSH machine {id}. Remote server is ready.");
|
|
println!("Open Herdr clients connect automatically.");
|
|
Ok(0)
|
|
}
|
|
|
|
fn rename(args: &[String]) -> std::io::Result<i32> {
|
|
let args = super::expand_equals_args(args, &["--label"]);
|
|
let [raw_id, flag, label] = args.as_slice() else {
|
|
eprintln!("usage: herdr machine rename <profile-id> --label <label>");
|
|
return Ok(2);
|
|
};
|
|
if flag != "--label" {
|
|
eprintln!("usage: herdr machine rename <profile-id> --label <label>");
|
|
return Ok(2);
|
|
}
|
|
let id = match ProfileId::parse(raw_id.clone()) {
|
|
Ok(id) => id,
|
|
Err(error) => {
|
|
eprintln!("error: {error}");
|
|
return Ok(2);
|
|
}
|
|
};
|
|
let mut catalog = load_catalog()?;
|
|
match catalog.rename_ssh(&id, label) {
|
|
Ok(true) => {}
|
|
Ok(false) => {
|
|
eprintln!("machine profile {id} was not found");
|
|
return Ok(1);
|
|
}
|
|
Err(error) => {
|
|
eprintln!("error: {error}");
|
|
return Ok(2);
|
|
}
|
|
}
|
|
store_catalog(&catalog)?;
|
|
println!("Renamed SSH machine {id}.");
|
|
Ok(0)
|
|
}
|
|
|
|
fn remove(args: &[String]) -> std::io::Result<i32> {
|
|
let Some(id) = one_profile_id(args, "usage: herdr machine remove <profile-id>")? else {
|
|
return Ok(2);
|
|
};
|
|
let mut catalog = load_catalog()?;
|
|
let previous_selection = catalog.selected_profile.clone();
|
|
let metadata_cache = catalog
|
|
.ssh
|
|
.iter()
|
|
.find(|profile| profile.id == id)
|
|
.map(|profile| {
|
|
crate::client::endpoint::SshMetadataCache::new(
|
|
id.as_str(),
|
|
&profile.target,
|
|
&profile.session,
|
|
)
|
|
})
|
|
.transpose()?;
|
|
if !catalog.remove_ssh(&id) {
|
|
eprintln!("machine profile {id} was not found");
|
|
return Ok(1);
|
|
}
|
|
store_catalog(&catalog)?;
|
|
if let Some(cache) = metadata_cache {
|
|
cache.invalidate();
|
|
}
|
|
if catalog.selected_profile != previous_selection {
|
|
catalog.store_selection().map_err(std::io::Error::other)?;
|
|
}
|
|
println!("Removed SSH machine {id}.");
|
|
Ok(0)
|
|
}
|
|
|
|
fn set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> {
|
|
let action = if enabled { "enable" } else { "disable" };
|
|
let usage = format!("usage: herdr machine {action} <profile-id>");
|
|
let Some(id) = one_profile_id(args, &usage)? else {
|
|
return Ok(2);
|
|
};
|
|
let mut catalog = load_catalog()?;
|
|
let previous_selection = catalog.selected_profile.clone();
|
|
if !catalog.set_enabled(&id, enabled) {
|
|
eprintln!("machine profile {id} was not found");
|
|
return Ok(1);
|
|
}
|
|
store_catalog(&catalog)?;
|
|
if catalog.selected_profile != previous_selection {
|
|
catalog.store_selection().map_err(std::io::Error::other)?;
|
|
}
|
|
println!(
|
|
"{} SSH machine {id}.",
|
|
if enabled { "Enabled" } else { "Disabled" }
|
|
);
|
|
Ok(0)
|
|
}
|
|
|
|
fn one_profile_id(args: &[String], usage: &str) -> std::io::Result<Option<ProfileId>> {
|
|
let [raw] = args else {
|
|
eprintln!("{usage}");
|
|
return Ok(None);
|
|
};
|
|
match ProfileId::parse(raw.clone()) {
|
|
Ok(id) => Ok(Some(id)),
|
|
Err(error) => {
|
|
eprintln!("error: {error}");
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_catalog() -> std::io::Result<EndpointCatalog> {
|
|
EndpointCatalog::load().map_err(std::io::Error::other)
|
|
}
|
|
|
|
fn store_catalog(catalog: &EndpointCatalog) -> std::io::Result<()> {
|
|
catalog.store_profiles().map_err(std::io::Error::other)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn add_parser_preserves_values_across_argument_orders() {
|
|
for (args, session) in [
|
|
(vec!["--label", "coder", "workstation.coder"], "default"),
|
|
(vec!["workstation.coder", "--label", "coder"], "default"),
|
|
(
|
|
vec![
|
|
"--remote-session",
|
|
"agents",
|
|
"workstation.coder",
|
|
"--label",
|
|
"coder",
|
|
],
|
|
"agents",
|
|
),
|
|
(
|
|
vec![
|
|
"--label=coder",
|
|
"--remote-session=agents",
|
|
"workstation.coder",
|
|
],
|
|
"agents",
|
|
),
|
|
] {
|
|
let args = args.into_iter().map(str::to_owned).collect::<Vec<_>>();
|
|
assert_eq!(
|
|
parse_add_args(&args).unwrap(),
|
|
AddArgs {
|
|
target: "workstation.coder".into(),
|
|
label: "coder".into(),
|
|
session: session.into(),
|
|
},
|
|
"{args:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn add_parser_rejects_incomplete_duplicate_and_extra_arguments() {
|
|
for args in [
|
|
vec![],
|
|
vec!["--label", "coder"],
|
|
vec!["workstation.coder"],
|
|
vec!["workstation.coder", "--label"],
|
|
vec!["workstation.coder", "--label", "coder", "--remote-session"],
|
|
vec!["--label", "coder", "--label", "other", "workstation.coder"],
|
|
vec![
|
|
"workstation.coder",
|
|
"--label",
|
|
"coder",
|
|
"--remote-session",
|
|
"a",
|
|
"--remote-session",
|
|
"b",
|
|
],
|
|
vec!["--label", "coder", "workstation.coder", "other-host"],
|
|
vec!["--unknown", "workstation.coder", "--label", "coder"],
|
|
vec!["--label", "--remote-session", "agents", "workstation.coder"],
|
|
] {
|
|
let args = args.into_iter().map(str::to_owned).collect::<Vec<_>>();
|
|
assert!(parse_add_args(&args).is_err(), "{args:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn profile_id_parser_rejects_target_text() {
|
|
assert!(one_profile_id(&["build.example".into()], "usage")
|
|
.unwrap()
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn list_rows_do_not_have_credential_fields() {
|
|
let encoded = serde_json::to_string(&MachineListRow {
|
|
id: "0123456789abcdef0123456789abcdef",
|
|
label: "Build",
|
|
target: "dev@build",
|
|
session: "agents",
|
|
enabled: true,
|
|
selected: false,
|
|
})
|
|
.unwrap();
|
|
assert!(!encoded.contains("password"));
|
|
assert!(!encoded.contains("key"));
|
|
}
|
|
}
|