Harden daemon service installation
This commit is contained in:
parent
443602a30b
commit
678865bfdc
6 changed files with 784 additions and 46 deletions
|
|
@ -4,7 +4,9 @@ use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
|||
use clap_complete::{Shell, generate};
|
||||
use geth_config::{ConfigKey, GethConfig, GethPaths};
|
||||
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus};
|
||||
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
|
||||
use geth_node::service::{
|
||||
ServiceInstallOptions, ServiceLogState, ServiceLogsReport, ServiceManager, ServiceReport,
|
||||
};
|
||||
use std::io::{Read, Write, stdout};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -24,6 +26,7 @@ Use `geth <command> --help` for details and `geth guide` for complete workflows.
|
|||
const DAEMON_AFTER_HELP: &str = r#"Examples:
|
||||
geth daemon install # persistent user service; starts immediately
|
||||
geth daemon status
|
||||
geth daemon logs
|
||||
geth daemon stop
|
||||
geth daemon start
|
||||
geth daemon uninstall
|
||||
|
|
@ -241,6 +244,8 @@ const GUIDE_SERVICE: &str = r#"Install geth as a user service:
|
|||
|
||||
geth daemon install
|
||||
geth daemon status
|
||||
geth daemon logs
|
||||
geth daemon logs --follow
|
||||
geth daemon stop
|
||||
geth daemon start
|
||||
geth daemon uninstall
|
||||
|
|
@ -250,6 +255,12 @@ Service installation targets user service managers, not system services:
|
|||
macOS: launchd user agent
|
||||
Windows: current-user scheduled task
|
||||
|
||||
`daemon install` waits for the local control endpoint to become ready. If the
|
||||
current executable is under a Cargo target or temporary directory, geth copies
|
||||
it into `<geth-home>/bin` before creating the service definition so `cargo
|
||||
clean` or temporary-file cleanup cannot break the service. Pass
|
||||
`--allow-transient-binary` only when that direct reference is intentional.
|
||||
|
||||
Preview definitions without installing:
|
||||
geth daemon service print
|
||||
|
||||
|
|
@ -590,6 +601,22 @@ pub enum DaemonCommand {
|
|||
manager: String,
|
||||
#[arg(long, help = "Executable path stored in the service definition")]
|
||||
bin: Option<PathBuf>,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Reference a temporary/Cargo-target binary directly instead of copying it into the geth home"
|
||||
)]
|
||||
allow_transient_binary: bool,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Return after starting without waiting for daemon readiness"
|
||||
)]
|
||||
no_wait: bool,
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 30_000,
|
||||
help = "Maximum readiness wait in milliseconds"
|
||||
)]
|
||||
timeout_ms: u64,
|
||||
},
|
||||
/// Start the installed user service
|
||||
Start {
|
||||
|
|
@ -606,6 +633,20 @@ pub enum DaemonCommand {
|
|||
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
|
||||
manager: String,
|
||||
},
|
||||
/// Show recent user-service logs or follow them
|
||||
Logs {
|
||||
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
|
||||
manager: String,
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 100,
|
||||
value_parser = parse_log_line_count,
|
||||
help = "Recent lines to show per log source"
|
||||
)]
|
||||
lines: usize,
|
||||
#[arg(long, help = "Continue following new log entries")]
|
||||
follow: bool,
|
||||
},
|
||||
/// Stop and remove the installed user service
|
||||
Uninstall {
|
||||
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
|
||||
|
|
@ -628,6 +669,11 @@ pub enum ServiceCommand {
|
|||
bin: Option<PathBuf>,
|
||||
#[arg(long, help = "Start the service immediately after installation")]
|
||||
start: bool,
|
||||
#[arg(
|
||||
long,
|
||||
help = "Reference a temporary/Cargo-target binary directly instead of copying it into the geth home"
|
||||
)]
|
||||
allow_transient_binary: bool,
|
||||
},
|
||||
/// Stop and remove the user service
|
||||
Uninstall {
|
||||
|
|
@ -1765,7 +1811,14 @@ async fn run_inner(cli: Cli) -> Result<()> {
|
|||
.context("run geth daemon")?;
|
||||
}
|
||||
Command::Daemon {
|
||||
command: DaemonCommand::Install { manager, bin },
|
||||
command:
|
||||
DaemonCommand::Install {
|
||||
manager,
|
||||
bin,
|
||||
allow_transient_binary,
|
||||
no_wait,
|
||||
timeout_ms,
|
||||
},
|
||||
} => {
|
||||
let report = run_service_command(
|
||||
&paths,
|
||||
|
|
@ -1773,45 +1826,89 @@ async fn run_inner(cli: Cli) -> Result<()> {
|
|||
manager,
|
||||
bin,
|
||||
start: true,
|
||||
allow_transient_binary,
|
||||
},
|
||||
)
|
||||
.context("install and start geth user service")?;
|
||||
print_service_report(report, cli.json || cli.jsonl)?;
|
||||
let readiness = if no_wait {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
run_wait_command(
|
||||
&paths,
|
||||
WaitCommand::Daemon {
|
||||
timeout_ms,
|
||||
interval_ms: 250,
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
print_daemon_install_report(&report, readiness.as_ref(), cli.json || cli.jsonl)?;
|
||||
if readiness.as_ref().is_some_and(|readiness| !readiness.ready) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Command::Daemon {
|
||||
command: DaemonCommand::Start { manager },
|
||||
} => {
|
||||
let report = run_service_command(&paths, ServiceCommand::Start { manager })
|
||||
.context("start geth user service; install it first with `geth daemon install`")?;
|
||||
print_service_report(report, cli.json || cli.jsonl)?;
|
||||
print_service_report(&report, cli.json || cli.jsonl)?;
|
||||
}
|
||||
Command::Daemon {
|
||||
command: DaemonCommand::Stop { manager },
|
||||
} => {
|
||||
let report = run_service_command(&paths, ServiceCommand::Stop { manager })
|
||||
.context("stop geth user service")?;
|
||||
print_service_report(report, cli.json || cli.jsonl)?;
|
||||
print_service_report(&report, cli.json || cli.jsonl)?;
|
||||
}
|
||||
Command::Daemon {
|
||||
command: DaemonCommand::Status { manager },
|
||||
} => {
|
||||
let report = run_service_command(&paths, ServiceCommand::Status { manager })
|
||||
.context("query geth user service; install it with `geth daemon install`")?;
|
||||
print_service_report(report, cli.json || cli.jsonl)?;
|
||||
print_service_report(&report, cli.json || cli.jsonl)?;
|
||||
}
|
||||
Command::Daemon {
|
||||
command:
|
||||
DaemonCommand::Logs {
|
||||
manager,
|
||||
lines,
|
||||
follow,
|
||||
},
|
||||
} => {
|
||||
if follow && (cli.json || cli.jsonl) {
|
||||
bail!("--follow cannot be combined with --json or --jsonl");
|
||||
}
|
||||
let report = geth_node::service::logs_user_service(
|
||||
&paths,
|
||||
manager.parse::<ServiceManager>()?,
|
||||
lines,
|
||||
follow,
|
||||
)
|
||||
.context("read geth user-service logs")?;
|
||||
print_service_logs_report(&report, cli.json || cli.jsonl)?;
|
||||
if matches!(
|
||||
report.state,
|
||||
ServiceLogState::NotInstalled | ServiceLogState::Unavailable
|
||||
) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Command::Daemon {
|
||||
command: DaemonCommand::Uninstall { manager },
|
||||
} => {
|
||||
let report = run_service_command(&paths, ServiceCommand::Uninstall { manager })
|
||||
.context("uninstall geth user service")?;
|
||||
print_service_report(report, cli.json || cli.jsonl)?;
|
||||
print_service_report(&report, cli.json || cli.jsonl)?;
|
||||
}
|
||||
Command::Daemon {
|
||||
command: DaemonCommand::Service { command },
|
||||
} => {
|
||||
let report =
|
||||
run_service_command(&paths, command).context("manage geth user service")?;
|
||||
print_service_report(report, cli.json || cli.jsonl)?;
|
||||
print_service_report(&report, cli.json || cli.jsonl)?;
|
||||
}
|
||||
Command::Wait { command } => {
|
||||
let report = run_wait_command(&paths, command).await?;
|
||||
|
|
@ -3162,18 +3259,27 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
|
|||
manager,
|
||||
bin,
|
||||
start,
|
||||
allow_transient_binary,
|
||||
} => {
|
||||
geth_node::init_node(paths).context("initialize geth home before service install")?;
|
||||
let manager = manager.parse::<ServiceManager>()?;
|
||||
let executable = service_executable(bin)?;
|
||||
geth_node::service::install_user_service(
|
||||
geth_node::init_node(paths).context("initialize geth home before service install")?;
|
||||
let (executable, copied_from) = service_executable(paths, bin, allow_transient_binary)?;
|
||||
let mut report = geth_node::service::install_user_service(
|
||||
paths,
|
||||
ServiceInstallOptions {
|
||||
manager,
|
||||
executable,
|
||||
start,
|
||||
},
|
||||
)?
|
||||
)?;
|
||||
if let Some(source) = copied_from {
|
||||
report.note = format!(
|
||||
"{}; copied transient executable from {} into the selected geth home",
|
||||
report.note,
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
report
|
||||
}
|
||||
ServiceCommand::Uninstall { manager } => {
|
||||
geth_node::service::uninstall_user_service(manager.parse::<ServiceManager>()?)?
|
||||
|
|
@ -3188,7 +3294,10 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
|
|||
geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)?
|
||||
}
|
||||
ServiceCommand::Print { manager, bin } => {
|
||||
let executable = service_executable(bin)?;
|
||||
let executable = bin
|
||||
.map(Ok)
|
||||
.unwrap_or_else(std::env::current_exe)
|
||||
.context("resolve geth executable for service preview")?;
|
||||
geth_node::service::print_user_service(
|
||||
paths,
|
||||
manager.parse::<ServiceManager>()?,
|
||||
|
|
@ -3198,10 +3307,82 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
|
|||
})
|
||||
}
|
||||
|
||||
fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> {
|
||||
bin.map(Ok)
|
||||
fn parse_log_line_count(value: &str) -> std::result::Result<usize, String> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|lines| *lines > 0)
|
||||
.ok_or_else(|| "log line count must be a positive integer".to_owned())
|
||||
}
|
||||
|
||||
fn service_executable(
|
||||
paths: &GethPaths,
|
||||
bin: Option<PathBuf>,
|
||||
allow_transient: bool,
|
||||
) -> Result<(PathBuf, Option<PathBuf>)> {
|
||||
let executable = bin
|
||||
.map(Ok)
|
||||
.unwrap_or_else(std::env::current_exe)
|
||||
.context("resolve current geth executable")
|
||||
.context("resolve current geth executable")?;
|
||||
let executable = executable
|
||||
.canonicalize()
|
||||
.with_context(|| format!("resolve service executable {}", executable.display()))?;
|
||||
if !executable.is_file() {
|
||||
bail!(
|
||||
"service executable is not a regular file: {}",
|
||||
executable.display()
|
||||
);
|
||||
}
|
||||
if !allow_transient && transient_executable_reason(&executable).is_some() {
|
||||
let bin_dir = paths.home().join("bin");
|
||||
std::fs::create_dir_all(&bin_dir).context("create durable service binary directory")?;
|
||||
let file_name = if cfg!(windows) { "geth.exe" } else { "geth" };
|
||||
let destination = bin_dir.join(file_name);
|
||||
let mut source = std::fs::File::open(&executable)
|
||||
.with_context(|| format!("open service executable {}", executable.display()))?;
|
||||
let permissions = source
|
||||
.metadata()
|
||||
.context("read service executable metadata")?
|
||||
.permissions();
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(&bin_dir)
|
||||
.context("create temporary service executable")?;
|
||||
std::io::copy(&mut source, temporary.as_file_mut())
|
||||
.context("copy service executable into geth home")?;
|
||||
temporary
|
||||
.as_file_mut()
|
||||
.set_permissions(permissions)
|
||||
.context("preserve service executable permissions")?;
|
||||
temporary
|
||||
.as_file_mut()
|
||||
.sync_all()
|
||||
.context("flush copied service executable")?;
|
||||
temporary
|
||||
.persist(&destination)
|
||||
.map_err(|error| error.error)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"install durable service executable at {}",
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
return Ok((destination, Some(executable)));
|
||||
}
|
||||
Ok((executable, None))
|
||||
}
|
||||
|
||||
fn transient_executable_reason(executable: &std::path::Path) -> Option<&'static str> {
|
||||
if executable.starts_with(std::env::temp_dir()) {
|
||||
return Some("temporary files can be removed while the service still references them");
|
||||
}
|
||||
let components = executable
|
||||
.components()
|
||||
.filter_map(|component| component.as_os_str().to_str())
|
||||
.map(|component| component.to_ascii_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
components.windows(2).find_map(|pair| {
|
||||
(pair[0] == "target" && matches!(pair[1].as_str(), "debug" | "release" | "deps"))
|
||||
.then_some("Cargo target artifacts are not a durable service installation path")
|
||||
})
|
||||
}
|
||||
|
||||
fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) {
|
||||
|
|
@ -4849,40 +5030,84 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
|
||||
fn print_daemon_install_report(
|
||||
report: &ServiceReport,
|
||||
readiness: Option<&WaitReport>,
|
||||
json: bool,
|
||||
) -> Result<()> {
|
||||
if json {
|
||||
let readiness = readiness.map(wait_report_json);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"type": "daemon-install",
|
||||
"service": service_report_json(report),
|
||||
"readiness": readiness,
|
||||
}))?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
print_service_report(report, false)?;
|
||||
if let Some(readiness) = readiness {
|
||||
println!("readiness:");
|
||||
println!(" ready: {}", readiness.ready);
|
||||
println!(" elapsed_ms: {}", readiness.elapsed_ms);
|
||||
println!(" reason: {}", readiness.reason);
|
||||
if !readiness.ready {
|
||||
println!(" next: inspect logs with `geth daemon logs`");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_report_json(report: &WaitReport) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"target": report.target,
|
||||
"ready": report.ready,
|
||||
"elapsed_ms": report.elapsed_ms,
|
||||
"attempts": report.attempts,
|
||||
"reason": report.reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn service_report_json(report: &ServiceReport) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"manager": report.manager.to_string(),
|
||||
"action": report.action.as_str(),
|
||||
"service_name": report.service_name,
|
||||
"state": report.state,
|
||||
"definition_path": report.definition_path,
|
||||
"definition": report.definition,
|
||||
"commands": report.commands,
|
||||
"note": report.note,
|
||||
})
|
||||
}
|
||||
|
||||
fn print_service_report(report: &ServiceReport, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"manager": report.manager.to_string(),
|
||||
"action": format!("{:?}", report.action),
|
||||
"service_name": report.service_name,
|
||||
"state": report.state,
|
||||
"definition_path": report.definition_path,
|
||||
"definition": report.definition,
|
||||
"commands": report.commands,
|
||||
"note": report.note,
|
||||
})
|
||||
serde_json::to_string_pretty(&service_report_json(report))?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("service: {}", report.service_name);
|
||||
println!("manager: {}", report.manager);
|
||||
println!("action: {:?}", report.action);
|
||||
println!("action: {}", report.action.as_str());
|
||||
if let Some(state) = &report.state {
|
||||
println!("state: {state}");
|
||||
}
|
||||
if let Some(path) = report.definition_path {
|
||||
if let Some(path) = &report.definition_path {
|
||||
println!("definition: {}", path.display());
|
||||
}
|
||||
if !report.commands.is_empty() {
|
||||
println!("commands:");
|
||||
for command in report.commands {
|
||||
println!(" {}", shell_quote_command(&command));
|
||||
for command in &report.commands {
|
||||
println!(" {}", shell_quote_command(command));
|
||||
}
|
||||
}
|
||||
if let Some(definition) = report.definition {
|
||||
if let Some(definition) = &report.definition {
|
||||
println!("definition_body:");
|
||||
print!("{definition}");
|
||||
}
|
||||
|
|
@ -4890,6 +5115,39 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn print_service_logs_report(report: &ServiceLogsReport, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"type": "service-logs",
|
||||
"manager": report.manager.to_string(),
|
||||
"service_name": report.service_name,
|
||||
"state": report.state.as_str(),
|
||||
"sources": report.sources,
|
||||
"command": report.command,
|
||||
"lines": report.lines,
|
||||
"note": report.note,
|
||||
}))?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
println!("service: {}", report.service_name);
|
||||
println!("manager: {}", report.manager);
|
||||
println!("state: {}", report.state.as_str());
|
||||
for source in &report.sources {
|
||||
println!("source: {}", source.display());
|
||||
}
|
||||
if let Some(command) = &report.command {
|
||||
println!("command: {}", shell_quote_command(command));
|
||||
}
|
||||
for line in &report.lines {
|
||||
println!("{line}");
|
||||
}
|
||||
println!("note: {}", report.note);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_file_conflict(conflict: &geth_cas::FileConflict) {
|
||||
println!("root: {}", conflict.root);
|
||||
println!("resource: {}", conflict.resource);
|
||||
|
|
@ -5010,7 +5268,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn common_daemon_lifecycle_commands_parse_directly() {
|
||||
for command in ["install", "start", "stop", "status", "uninstall"] {
|
||||
for command in ["install", "start", "stop", "status", "logs", "uninstall"] {
|
||||
let parsed = Cli::try_parse_from(["geth", "daemon", command]);
|
||||
assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}");
|
||||
}
|
||||
|
|
@ -5022,6 +5280,48 @@ mod tests {
|
|||
command: DaemonCommand::Run { ephemeral: true }
|
||||
}
|
||||
));
|
||||
assert!(Cli::try_parse_from(["geth", "daemon", "logs", "--lines", "0"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_install_copies_transient_binaries_unless_explicitly_overridden() {
|
||||
assert!(
|
||||
transient_executable_reason(std::path::Path::new("/work/geth/target/debug/geth"))
|
||||
.is_some()
|
||||
);
|
||||
assert!(transient_executable_reason(std::path::Path::new("/usr/local/bin/geth")).is_none());
|
||||
|
||||
let binary = tempfile::NamedTempFile::new().expect("temporary binary");
|
||||
std::fs::write(binary.path(), b"geth-test-binary").expect("write binary");
|
||||
let home = tempfile::tempdir().expect("temporary geth home");
|
||||
let paths = GethPaths::from_home(home.path());
|
||||
let (copied, source) = service_executable(&paths, Some(binary.path().to_path_buf()), false)
|
||||
.expect("copy transient binary");
|
||||
assert_eq!(
|
||||
copied,
|
||||
paths
|
||||
.home()
|
||||
.join("bin")
|
||||
.join(if cfg!(windows) { "geth.exe" } else { "geth" })
|
||||
);
|
||||
assert_eq!(source, Some(binary.path().canonicalize().expect("source")));
|
||||
assert_eq!(
|
||||
std::fs::read(copied).expect("read copy"),
|
||||
b"geth-test-binary"
|
||||
);
|
||||
|
||||
std::fs::write(binary.path(), b"geth-updated-binary").expect("update source binary");
|
||||
let (copied, _) = service_executable(&paths, Some(binary.path().to_path_buf()), false)
|
||||
.expect("replace copied binary");
|
||||
assert_eq!(
|
||||
std::fs::read(copied).expect("read replacement"),
|
||||
b"geth-updated-binary"
|
||||
);
|
||||
|
||||
let (direct, source) = service_executable(&paths, Some(binary.path().to_path_buf()), true)
|
||||
.expect("allow transient binary");
|
||||
assert_eq!(direct, binary.path().canonicalize().expect("source"));
|
||||
assert!(source.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -68,6 +68,51 @@ pub enum ServiceAction {
|
|||
Printed,
|
||||
}
|
||||
|
||||
impl ServiceAction {
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Installed => "installed",
|
||||
Self::Uninstalled => "uninstalled",
|
||||
Self::Started => "started",
|
||||
Self::Stopped => "stopped",
|
||||
Self::Status => "status",
|
||||
Self::Printed => "printed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ServiceLogState {
|
||||
Available,
|
||||
Empty,
|
||||
NotInstalled,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl ServiceLogState {
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Available => "available",
|
||||
Self::Empty => "empty",
|
||||
Self::NotInstalled => "not-installed",
|
||||
Self::Unavailable => "unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ServiceLogsReport {
|
||||
pub manager: ServiceManager,
|
||||
pub service_name: String,
|
||||
pub state: ServiceLogState,
|
||||
pub sources: Vec<PathBuf>,
|
||||
pub command: Option<Vec<String>>,
|
||||
pub lines: Vec<String>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ServiceReport {
|
||||
pub manager: ServiceManager,
|
||||
|
|
@ -261,6 +306,22 @@ pub fn status_user_service(manager: ServiceManager) -> Result<ServiceReport, Ser
|
|||
}
|
||||
}
|
||||
|
||||
pub fn logs_user_service(
|
||||
paths: &GethPaths,
|
||||
manager: ServiceManager,
|
||||
lines: usize,
|
||||
follow: bool,
|
||||
) -> Result<ServiceLogsReport, ServiceError> {
|
||||
let manager = manager.resolve()?;
|
||||
let lines = lines.max(1);
|
||||
match manager {
|
||||
ServiceManager::SystemdUser => systemd_user_logs(manager, lines, follow),
|
||||
ServiceManager::LaunchdUser => launchd_user_logs(paths, manager, lines, follow),
|
||||
ServiceManager::WindowsTask => windows_task_logs(paths, manager, lines, follow),
|
||||
ServiceManager::Auto => unreachable!("auto is resolved above"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_user_service(
|
||||
paths: &GethPaths,
|
||||
manager: ServiceManager,
|
||||
|
|
@ -292,6 +353,310 @@ const SYSTEMD_UNIT: &str = "geth.service";
|
|||
const LAUNCHD_LABEL: &str = "local.geth.daemon";
|
||||
const WINDOWS_TASK_NAME: &str = "geth-daemon";
|
||||
|
||||
fn systemd_user_logs(
|
||||
manager: ServiceManager,
|
||||
lines: usize,
|
||||
follow: bool,
|
||||
) -> Result<ServiceLogsReport, ServiceError> {
|
||||
if !systemd_unit_path()?.is_file() {
|
||||
return Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::NotInstalled,
|
||||
Vec::new(),
|
||||
Some(vec![
|
||||
"journalctl".to_owned(),
|
||||
"--user-unit".to_owned(),
|
||||
SYSTEMD_UNIT.to_owned(),
|
||||
]),
|
||||
Vec::new(),
|
||||
"systemd user unit is not installed; run `geth daemon install` first",
|
||||
));
|
||||
}
|
||||
let mut args = vec![
|
||||
"--user-unit".to_owned(),
|
||||
SYSTEMD_UNIT.to_owned(),
|
||||
"--lines".to_owned(),
|
||||
lines.to_string(),
|
||||
"--no-pager".to_owned(),
|
||||
];
|
||||
if follow {
|
||||
args.push("--follow".to_owned());
|
||||
}
|
||||
command_logs(
|
||||
manager,
|
||||
"journalctl",
|
||||
args,
|
||||
follow,
|
||||
Vec::new(),
|
||||
"systemd user journal",
|
||||
)
|
||||
}
|
||||
|
||||
fn launchd_user_logs(
|
||||
paths: &GethPaths,
|
||||
manager: ServiceManager,
|
||||
lines: usize,
|
||||
follow: bool,
|
||||
) -> Result<ServiceLogsReport, ServiceError> {
|
||||
if !launchd_plist_path()?.is_file() {
|
||||
return Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::NotInstalled,
|
||||
daemon_log_paths(paths),
|
||||
None,
|
||||
Vec::new(),
|
||||
"launchd user agent is not installed; run `geth daemon install` first",
|
||||
));
|
||||
}
|
||||
file_service_logs(paths, manager, lines, follow, false)
|
||||
}
|
||||
|
||||
fn windows_task_logs(
|
||||
paths: &GethPaths,
|
||||
manager: ServiceManager,
|
||||
lines: usize,
|
||||
follow: bool,
|
||||
) -> Result<ServiceLogsReport, ServiceError> {
|
||||
let status = match status_user_service(manager.clone()) {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
return Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
daemon_log_paths(paths),
|
||||
Some(vec![
|
||||
"schtasks".to_owned(),
|
||||
"/Query".to_owned(),
|
||||
"/TN".to_owned(),
|
||||
WINDOWS_TASK_NAME.to_owned(),
|
||||
]),
|
||||
Vec::new(),
|
||||
&format!("could not query the per-user scheduled task: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
if status.state.as_deref() == Some("not-installed") {
|
||||
return Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::NotInstalled,
|
||||
daemon_log_paths(paths),
|
||||
None,
|
||||
Vec::new(),
|
||||
"Windows per-user task is not installed; run `geth daemon install` first",
|
||||
));
|
||||
}
|
||||
if status.state.as_deref() == Some("unknown") {
|
||||
return Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
daemon_log_paths(paths),
|
||||
status.commands.into_iter().next(),
|
||||
Vec::new(),
|
||||
&status.note,
|
||||
));
|
||||
}
|
||||
file_service_logs(paths, manager, lines, follow, true)
|
||||
}
|
||||
|
||||
fn file_service_logs(
|
||||
paths: &GethPaths,
|
||||
manager: ServiceManager,
|
||||
lines: usize,
|
||||
follow: bool,
|
||||
powershell: bool,
|
||||
) -> Result<ServiceLogsReport, ServiceError> {
|
||||
let sources = daemon_log_paths(paths);
|
||||
let existing = sources
|
||||
.iter()
|
||||
.filter(|path| path.is_file())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if existing.is_empty() {
|
||||
return Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Empty,
|
||||
sources,
|
||||
None,
|
||||
Vec::new(),
|
||||
"service is installed but has not written any log entries",
|
||||
));
|
||||
}
|
||||
if follow {
|
||||
if powershell {
|
||||
let paths = existing
|
||||
.iter()
|
||||
.map(|path| format!("'{}'", path.display().to_string().replace('\'', "''")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let script = format!("Get-Content -Path {paths} -Tail {lines} -Wait");
|
||||
return command_logs(
|
||||
manager,
|
||||
"powershell",
|
||||
vec!["-NoProfile".to_owned(), "-Command".to_owned(), script],
|
||||
true,
|
||||
sources,
|
||||
"Windows daemon log files",
|
||||
);
|
||||
}
|
||||
let mut args = vec!["-n".to_owned(), lines.to_string(), "-f".to_owned()];
|
||||
args.extend(existing.iter().map(|path| path.display().to_string()));
|
||||
return command_logs(
|
||||
manager,
|
||||
"tail",
|
||||
args,
|
||||
true,
|
||||
sources,
|
||||
"launchd daemon log files",
|
||||
);
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
for path in &existing {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
let file_lines = content.lines().collect::<Vec<_>>();
|
||||
let start = file_lines.len().saturating_sub(lines);
|
||||
let source = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("daemon.log");
|
||||
output.extend(
|
||||
file_lines[start..]
|
||||
.iter()
|
||||
.map(|line| format!("{source}: {line}")),
|
||||
);
|
||||
}
|
||||
let (state, note) = if output.is_empty() {
|
||||
(
|
||||
ServiceLogState::Empty,
|
||||
"service log files exist but contain no entries",
|
||||
)
|
||||
} else {
|
||||
(ServiceLogState::Available, "read daemon log files")
|
||||
};
|
||||
Ok(log_report(manager, state, sources, None, output, note))
|
||||
}
|
||||
|
||||
fn command_logs(
|
||||
manager: ServiceManager,
|
||||
program: &str,
|
||||
args: Vec<String>,
|
||||
follow: bool,
|
||||
sources: Vec<PathBuf>,
|
||||
description: &str,
|
||||
) -> Result<ServiceLogsReport, ServiceError> {
|
||||
let command = std::iter::once(program.to_owned())
|
||||
.chain(args.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
if follow {
|
||||
return match Command::new(program).args(&args).status() {
|
||||
Ok(status) if status.success() => Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Available,
|
||||
sources,
|
||||
Some(command),
|
||||
Vec::new(),
|
||||
&format!("finished following {description}"),
|
||||
)),
|
||||
Ok(status) => Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
sources,
|
||||
Some(command),
|
||||
Vec::new(),
|
||||
&format!("log command exited with status {status}"),
|
||||
)),
|
||||
Err(error) => Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
sources,
|
||||
Some(command),
|
||||
Vec::new(),
|
||||
&format!("could not run log command: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
match Command::new(program).args(&args).output() {
|
||||
Ok(output) if output.status.success() => {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let lines = text
|
||||
.lines()
|
||||
.filter(|line| *line != "-- No entries --")
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let (state, note) = if lines.is_empty() {
|
||||
(
|
||||
ServiceLogState::Empty,
|
||||
format!("{description} contains no entries"),
|
||||
)
|
||||
} else {
|
||||
(ServiceLogState::Available, format!("read {description}"))
|
||||
};
|
||||
Ok(log_report(
|
||||
manager,
|
||||
state,
|
||||
sources,
|
||||
Some(command),
|
||||
lines,
|
||||
¬e,
|
||||
))
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
sources,
|
||||
Some(command),
|
||||
Vec::new(),
|
||||
&format!("could not read {description}: {stderr}"),
|
||||
))
|
||||
}
|
||||
Err(error) => Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
sources,
|
||||
Some(command),
|
||||
Vec::new(),
|
||||
&format!("could not run log command: {error}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_log_paths(paths: &GethPaths) -> Vec<PathBuf> {
|
||||
vec![
|
||||
paths.run_dir().join("daemon.out.log"),
|
||||
paths.run_dir().join("daemon.err.log"),
|
||||
]
|
||||
}
|
||||
|
||||
fn log_report(
|
||||
manager: ServiceManager,
|
||||
state: ServiceLogState,
|
||||
sources: Vec<PathBuf>,
|
||||
command: Option<Vec<String>>,
|
||||
lines: Vec<String>,
|
||||
note: &str,
|
||||
) -> ServiceLogsReport {
|
||||
let service_name = match &manager {
|
||||
ServiceManager::SystemdUser => SYSTEMD_UNIT,
|
||||
ServiceManager::LaunchdUser => LAUNCHD_LABEL,
|
||||
ServiceManager::WindowsTask => WINDOWS_TASK_NAME,
|
||||
ServiceManager::Auto => "geth",
|
||||
}
|
||||
.to_owned();
|
||||
ServiceLogsReport {
|
||||
manager,
|
||||
service_name,
|
||||
state,
|
||||
sources,
|
||||
command,
|
||||
lines,
|
||||
note: note.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_systemd_user(
|
||||
paths: &GethPaths,
|
||||
executable: &Path,
|
||||
|
|
@ -505,9 +870,11 @@ fn windows_task_command(paths: &GethPaths, executable: &Path) -> String {
|
|||
|
||||
fn windows_task_run_command(paths: &GethPaths, executable: &Path) -> String {
|
||||
format!(
|
||||
r#"cmd.exe /C "set GETH_HOME={}&& "{}" daemon run""#,
|
||||
r#"cmd.exe /D /C "set "GETH_HOME={}" && "{}" daemon run 1>>"{}" 2>>"{}"""#,
|
||||
paths.home().display(),
|
||||
executable.display()
|
||||
executable.display(),
|
||||
paths.run_dir().join("daemon.out.log").display(),
|
||||
paths.run_dir().join("daemon.err.log").display()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -691,9 +1058,38 @@ mod tests {
|
|||
fn windows_task_uses_current_user_logon_trigger() {
|
||||
let paths = GethPaths::from_home(r"C:\Users\Eric\AppData\Local\geth");
|
||||
let command = windows_task_run_command(&paths, Path::new(r"C:\bin\geth.exe"));
|
||||
assert!(command.contains("cmd.exe /C"));
|
||||
assert!(command.contains("cmd.exe /D /C"));
|
||||
assert!(command.contains("GETH_HOME="));
|
||||
assert!(command.contains("daemon run"));
|
||||
assert!(command.contains("daemon.out.log"));
|
||||
assert!(command.contains("daemon.err.log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_service_logs_distinguish_empty_and_available_logs() {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let paths = GethPaths::from_home(home.path());
|
||||
paths.ensure_base_dirs().expect("base dirs");
|
||||
|
||||
let empty = file_service_logs(&paths, ServiceManager::LaunchdUser, 20, false, false)
|
||||
.expect("empty report");
|
||||
assert_eq!(empty.state, ServiceLogState::Empty);
|
||||
|
||||
std::fs::write(
|
||||
paths.run_dir().join("daemon.out.log"),
|
||||
"old\nnewer\nnewest\n",
|
||||
)
|
||||
.expect("write log");
|
||||
let available = file_service_logs(&paths, ServiceManager::LaunchdUser, 2, false, false)
|
||||
.expect("available report");
|
||||
assert_eq!(available.state, ServiceLogState::Available);
|
||||
assert_eq!(
|
||||
available.lines,
|
||||
vec![
|
||||
"daemon.out.log: newer".to_owned(),
|
||||
"daemon.out.log: newest".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue