Improve CLI and daemon lifecycle usability
This commit is contained in:
parent
afec7063db
commit
f61cb44dad
14 changed files with 1028 additions and 204 deletions
|
|
@ -11,6 +11,7 @@ base64.workspace = true
|
|||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
geth-config = { path = "../geth-config" }
|
||||
geth-control = { path = "../geth-control" }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -92,7 +92,7 @@ async fn serve_local_control(node: LocalNode, listener: UnixListener) -> Result<
|
|||
}
|
||||
});
|
||||
}
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal = shutdown_signal() => {
|
||||
signal?;
|
||||
tracing::info!("shutdown signal received");
|
||||
return Ok(());
|
||||
|
|
@ -101,6 +101,22 @@ async fn serve_local_control(node: LocalNode, listener: UnixListener) -> Result<
|
|||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() -> Result<(), std::io::Error> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut terminate =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => result,
|
||||
_ = terminate.recv() => Ok(()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c().await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_iroh_control_accept_loop(
|
||||
node: LocalNode,
|
||||
endpoint: GethIrohEndpoint,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ pub struct ServiceReport {
|
|||
pub manager: ServiceManager,
|
||||
pub action: ServiceAction,
|
||||
pub service_name: String,
|
||||
pub state: Option<String>,
|
||||
pub definition_path: Option<PathBuf>,
|
||||
pub definition: Option<String>,
|
||||
pub commands: Vec<Vec<String>>,
|
||||
|
|
@ -223,39 +224,37 @@ pub fn status_user_service(manager: ServiceManager) -> Result<ServiceReport, Ser
|
|||
let manager = manager.resolve()?;
|
||||
match manager {
|
||||
ServiceManager::SystemdUser => {
|
||||
let command = run_command(
|
||||
let (command, code, detail) = inspect_command(
|
||||
"systemctl",
|
||||
&["--user", "status", SYSTEMD_UNIT, "--no-pager"],
|
||||
)?;
|
||||
Ok(report(
|
||||
Ok(status_report(
|
||||
manager,
|
||||
ServiceAction::Status,
|
||||
Some(systemd_unit_path()?),
|
||||
None,
|
||||
vec![command],
|
||||
"queried systemd user service",
|
||||
command,
|
||||
systemd_state(code),
|
||||
&detail,
|
||||
))
|
||||
}
|
||||
ServiceManager::LaunchdUser => {
|
||||
let command = run_command("launchctl", &["list", LAUNCHD_LABEL])?;
|
||||
Ok(report(
|
||||
let (command, code, detail) = inspect_command("launchctl", &["list", LAUNCHD_LABEL])?;
|
||||
Ok(status_report(
|
||||
manager,
|
||||
ServiceAction::Status,
|
||||
Some(launchd_plist_path()?),
|
||||
None,
|
||||
vec![command],
|
||||
"queried launchd user agent",
|
||||
command,
|
||||
launchd_state(code, &detail),
|
||||
&detail,
|
||||
))
|
||||
}
|
||||
ServiceManager::WindowsTask => {
|
||||
let command = run_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?;
|
||||
Ok(report(
|
||||
let (command, code, detail) =
|
||||
inspect_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?;
|
||||
Ok(status_report(
|
||||
manager,
|
||||
ServiceAction::Status,
|
||||
None,
|
||||
None,
|
||||
vec![command],
|
||||
"queried Windows per-user scheduled task",
|
||||
command,
|
||||
windows_task_state(code, &detail),
|
||||
&detail,
|
||||
))
|
||||
}
|
||||
ServiceManager::Auto => unreachable!("auto is resolved above"),
|
||||
|
|
@ -358,12 +357,12 @@ fn install_launchd_user(
|
|||
}
|
||||
let definition = launchd_plist(paths, executable);
|
||||
std::fs::write(&plist_path, &definition)?;
|
||||
let mut commands = vec![run_command(
|
||||
"launchctl",
|
||||
&["load", "-w", &plist_path.display().to_string()],
|
||||
)?];
|
||||
let mut commands = Vec::new();
|
||||
if start {
|
||||
commands.push(run_command("launchctl", &["start", LAUNCHD_LABEL])?);
|
||||
commands.push(run_command(
|
||||
"launchctl",
|
||||
&["load", "-w", &plist_path.display().to_string()],
|
||||
)?);
|
||||
}
|
||||
Ok(report(
|
||||
ServiceManager::LaunchdUser,
|
||||
|
|
@ -379,10 +378,9 @@ fn uninstall_launchd_user() -> Result<ServiceReport, ServiceError> {
|
|||
let plist_path = launchd_plist_path()?;
|
||||
let mut commands = Vec::new();
|
||||
if plist_path.exists() {
|
||||
commands.push(run_command(
|
||||
"launchctl",
|
||||
&["unload", &plist_path.display().to_string()],
|
||||
)?);
|
||||
let (command, _, _) =
|
||||
inspect_command("launchctl", &["unload", &plist_path.display().to_string()])?;
|
||||
commands.push(command);
|
||||
std::fs::remove_file(&plist_path)?;
|
||||
}
|
||||
Ok(report(
|
||||
|
|
@ -546,6 +544,83 @@ fn run_command(program: &str, args: &[&str]) -> Result<Vec<String>, ServiceError
|
|||
}
|
||||
}
|
||||
|
||||
fn inspect_command(
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
) -> Result<(Vec<String>, Option<i32>, String), ServiceError> {
|
||||
let output = Command::new(program).args(args).output()?;
|
||||
let command = std::iter::once(program.to_owned())
|
||||
.chain(args.iter().map(|arg| (*arg).to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
let detail = if stdout.is_empty() { stderr } else { stdout }
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
Ok((command, output.status.code(), detail))
|
||||
}
|
||||
|
||||
fn systemd_state(code: Option<i32>) -> &'static str {
|
||||
match code {
|
||||
Some(0) => "running",
|
||||
Some(3) => "not-running",
|
||||
Some(4) => "not-installed",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn launchd_state(code: Option<i32>, detail: &str) -> &'static str {
|
||||
if code == Some(0) {
|
||||
"loaded"
|
||||
} else if detail
|
||||
.to_ascii_lowercase()
|
||||
.contains("could not find service")
|
||||
{
|
||||
"not-loaded"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_task_state(code: Option<i32>, detail: &str) -> &'static str {
|
||||
if code == Some(0) {
|
||||
"installed"
|
||||
} else {
|
||||
let detail = detail.to_ascii_lowercase();
|
||||
if detail.contains("cannot find") || detail.contains("does not exist") {
|
||||
"not-installed"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_report(
|
||||
manager: ServiceManager,
|
||||
definition_path: Option<PathBuf>,
|
||||
command: Vec<String>,
|
||||
state: &str,
|
||||
detail: &str,
|
||||
) -> ServiceReport {
|
||||
let note = if detail.is_empty() {
|
||||
format!("user service is {state}")
|
||||
} else {
|
||||
format!("user service is {state}: {detail}")
|
||||
};
|
||||
let mut report = report(
|
||||
manager,
|
||||
ServiceAction::Status,
|
||||
definition_path,
|
||||
None,
|
||||
vec![command],
|
||||
¬e,
|
||||
);
|
||||
report.state = Some(state.to_owned());
|
||||
report
|
||||
}
|
||||
|
||||
fn report(
|
||||
manager: ServiceManager,
|
||||
action: ServiceAction,
|
||||
|
|
@ -563,6 +638,7 @@ fn report(
|
|||
},
|
||||
manager,
|
||||
action,
|
||||
state: None,
|
||||
definition_path,
|
||||
definition,
|
||||
commands,
|
||||
|
|
@ -619,4 +695,35 @@ mod tests {
|
|||
assert!(command.contains("GETH_HOME="));
|
||||
assert!(command.contains("daemon run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_status_reports_non_running_state_without_an_action_error() {
|
||||
let report = status_report(
|
||||
ServiceManager::SystemdUser,
|
||||
Some(PathBuf::from("/tmp/geth.service")),
|
||||
vec!["systemctl".to_owned(), "status".to_owned()],
|
||||
"not-running",
|
||||
"Unit geth.service could not be found.",
|
||||
);
|
||||
assert_eq!(report.action, ServiceAction::Status);
|
||||
assert_eq!(report.state.as_deref(), Some("not-running"));
|
||||
assert!(report.note.contains("could not be found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_status_classification_preserves_manager_errors_as_unknown() {
|
||||
assert_eq!(systemd_state(Some(0)), "running");
|
||||
assert_eq!(systemd_state(Some(3)), "not-running");
|
||||
assert_eq!(systemd_state(Some(4)), "not-installed");
|
||||
assert_eq!(systemd_state(Some(1)), "unknown");
|
||||
assert_eq!(
|
||||
launchd_state(Some(1), "Could not find service local.geth.daemon"),
|
||||
"not-loaded"
|
||||
);
|
||||
assert_eq!(launchd_state(Some(1), "operation not permitted"), "unknown");
|
||||
assert_eq!(
|
||||
windows_task_state(Some(1), "ERROR: The system cannot find the file specified."),
|
||||
"not-installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,6 +325,41 @@ fn cli_help_documents_owner_init_keys() {
|
|||
assert!(stdout.contains("geth guide owner-setup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_help_describes_lifecycle_and_resource_commands() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let output = run_geth(home.path(), &["--help"]);
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("daemon Run, install, and manage the daemon"));
|
||||
assert!(stdout.contains("status Show daemon, storage, Iroh, and backend health"));
|
||||
assert!(stdout.contains("cas Store, fetch, pin, and synchronize"));
|
||||
assert!(stdout.contains("geth daemon install"));
|
||||
assert!(stdout.contains("geth daemon run --ephemeral"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_flag_overrides_geth_home_for_initialization() {
|
||||
let environment_home = tempfile::tempdir().expect("environment home");
|
||||
let selected_parent = tempfile::tempdir().expect("selected parent");
|
||||
let selected_home = selected_parent.path().join("selected-home");
|
||||
let output = run_geth(
|
||||
environment_home.path(),
|
||||
&[
|
||||
"--home",
|
||||
selected_home.to_str().expect("selected home"),
|
||||
"init",
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(selected_home.join("geth.sqlite").exists());
|
||||
assert!(!environment_home.path().join("geth.sqlite").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_command_explains_key_roles() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
|
|
@ -535,6 +570,12 @@ fn json_errors_include_stable_code_for_common_failures() {
|
|||
.expect("error message")
|
||||
.contains("connect to daemon")
|
||||
);
|
||||
assert!(
|
||||
error["hint"]
|
||||
.as_str()
|
||||
.expect("daemon recovery hint")
|
||||
.contains("geth daemon install")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue