Harden daemon service installation
This commit is contained in:
parent
443602a30b
commit
678865bfdc
6 changed files with 784 additions and 46 deletions
17
README.md
17
README.md
|
|
@ -25,7 +25,8 @@ Keep the daemon running. In a second shell, reuse the home it printed:
|
||||||
|
|
||||||
Ctrl-C stops the daemon and removes its temporary state. For a persistent
|
Ctrl-C stops the daemon and removes its temporary state. For a persistent
|
||||||
background node, run `geth daemon install`; this initializes local state,
|
background node, run `geth daemon install`; this initializes local state,
|
||||||
installs a service for the current user, and starts it immediately. Before
|
installs a service for the current user, starts it, and waits for the daemon to
|
||||||
|
answer on local control. Before
|
||||||
enrolling other machines, use `geth guide owner-setup`; it records an
|
enrolling other machines, use `geth guide owner-setup`; it records an
|
||||||
OpenSSH admin public key as the trust anchor and signs the initial keychain
|
OpenSSH admin public key as the trust anchor and signs the initial keychain
|
||||||
statements without copying the private key into geth state.
|
statements without copying the private key into geth state.
|
||||||
|
|
@ -92,6 +93,7 @@ The daemon can also install itself as a user service:
|
||||||
```sh
|
```sh
|
||||||
geth daemon install
|
geth daemon install
|
||||||
geth daemon status
|
geth daemon status
|
||||||
|
geth daemon logs
|
||||||
geth daemon uninstall
|
geth daemon uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -100,6 +102,16 @@ agents on macOS, and per-user scheduled tasks on Windows. These are user-level
|
||||||
services, not system services. The longer `geth daemon service ...` family is
|
services, not system services. The longer `geth daemon service ...` family is
|
||||||
retained for compatibility and advanced options.
|
retained for compatibility and advanced options.
|
||||||
|
|
||||||
|
`daemon install` canonicalizes and validates the executable recorded in the
|
||||||
|
service definition. When invoked from a Cargo `target` directory or another
|
||||||
|
temporary location, it atomically copies the binary into
|
||||||
|
`<geth-home>/bin/geth` first so cleanup cannot leave a broken service. Use
|
||||||
|
`--allow-transient-binary` only for deliberate development setups. Installation
|
||||||
|
waits up to 30 seconds for local daemon readiness by default; use `--no-wait` or
|
||||||
|
`--timeout-ms` when automation needs different behavior. `geth daemon logs`
|
||||||
|
shows the systemd user journal or the selected home's launchd/Windows log files;
|
||||||
|
add `--follow` to stream new entries.
|
||||||
|
|
||||||
## Transport And SSH
|
## Transport And SSH
|
||||||
|
|
||||||
All remote node-to-node geth communication is designed to happen over Iroh only.
|
All remote node-to-node geth communication is designed to happen over Iroh only.
|
||||||
|
|
@ -139,8 +151,9 @@ The bootstrap implementation provides:
|
||||||
- `geth init`
|
- `geth init`
|
||||||
- `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>`
|
- `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>`
|
||||||
- `geth daemon run [--ephemeral]`
|
- `geth daemon run [--ephemeral]`
|
||||||
- `geth daemon install|start|stop|status|uninstall`
|
- `geth daemon install|start|stop|status|logs|uninstall`
|
||||||
- `geth daemon service install|uninstall|start|stop|status|print`
|
- `geth daemon service install|uninstall|start|stop|status|print`
|
||||||
|
- `geth config path|show|validate|set`
|
||||||
- `geth status`
|
- `geth status`
|
||||||
- `geth wait daemon|peer|sync --timeout-ms <ms>`
|
- `geth wait daemon|peer|sync --timeout-ms <ms>`
|
||||||
- `geth doctor`
|
- `geth doctor`
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
||||||
use clap_complete::{Shell, generate};
|
use clap_complete::{Shell, generate};
|
||||||
use geth_config::{ConfigKey, GethConfig, GethPaths};
|
use geth_config::{ConfigKey, GethConfig, GethPaths};
|
||||||
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus};
|
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::io::{Read, Write, stdout};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
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:
|
const DAEMON_AFTER_HELP: &str = r#"Examples:
|
||||||
geth daemon install # persistent user service; starts immediately
|
geth daemon install # persistent user service; starts immediately
|
||||||
geth daemon status
|
geth daemon status
|
||||||
|
geth daemon logs
|
||||||
geth daemon stop
|
geth daemon stop
|
||||||
geth daemon start
|
geth daemon start
|
||||||
geth daemon uninstall
|
geth daemon uninstall
|
||||||
|
|
@ -241,6 +244,8 @@ const GUIDE_SERVICE: &str = r#"Install geth as a user service:
|
||||||
|
|
||||||
geth daemon install
|
geth daemon install
|
||||||
geth daemon status
|
geth daemon status
|
||||||
|
geth daemon logs
|
||||||
|
geth daemon logs --follow
|
||||||
geth daemon stop
|
geth daemon stop
|
||||||
geth daemon start
|
geth daemon start
|
||||||
geth daemon uninstall
|
geth daemon uninstall
|
||||||
|
|
@ -250,6 +255,12 @@ Service installation targets user service managers, not system services:
|
||||||
macOS: launchd user agent
|
macOS: launchd user agent
|
||||||
Windows: current-user scheduled task
|
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:
|
Preview definitions without installing:
|
||||||
geth daemon service print
|
geth daemon service print
|
||||||
|
|
||||||
|
|
@ -590,6 +601,22 @@ pub enum DaemonCommand {
|
||||||
manager: String,
|
manager: String,
|
||||||
#[arg(long, help = "Executable path stored in the service definition")]
|
#[arg(long, help = "Executable path stored in the service definition")]
|
||||||
bin: Option<PathBuf>,
|
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 the installed user service
|
||||||
Start {
|
Start {
|
||||||
|
|
@ -606,6 +633,20 @@ pub enum DaemonCommand {
|
||||||
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
|
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
|
||||||
manager: String,
|
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
|
/// Stop and remove the installed user service
|
||||||
Uninstall {
|
Uninstall {
|
||||||
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
|
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
|
||||||
|
|
@ -628,6 +669,11 @@ pub enum ServiceCommand {
|
||||||
bin: Option<PathBuf>,
|
bin: Option<PathBuf>,
|
||||||
#[arg(long, help = "Start the service immediately after installation")]
|
#[arg(long, help = "Start the service immediately after installation")]
|
||||||
start: bool,
|
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
|
/// Stop and remove the user service
|
||||||
Uninstall {
|
Uninstall {
|
||||||
|
|
@ -1765,7 +1811,14 @@ async fn run_inner(cli: Cli) -> Result<()> {
|
||||||
.context("run geth daemon")?;
|
.context("run geth daemon")?;
|
||||||
}
|
}
|
||||||
Command::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(
|
let report = run_service_command(
|
||||||
&paths,
|
&paths,
|
||||||
|
|
@ -1773,45 +1826,89 @@ async fn run_inner(cli: Cli) -> Result<()> {
|
||||||
manager,
|
manager,
|
||||||
bin,
|
bin,
|
||||||
start: true,
|
start: true,
|
||||||
|
allow_transient_binary,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.context("install and start geth user service")?;
|
.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::Daemon {
|
||||||
command: DaemonCommand::Start { manager },
|
command: DaemonCommand::Start { manager },
|
||||||
} => {
|
} => {
|
||||||
let report = run_service_command(&paths, ServiceCommand::Start { manager })
|
let report = run_service_command(&paths, ServiceCommand::Start { manager })
|
||||||
.context("start geth user service; install it first with `geth daemon install`")?;
|
.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::Daemon {
|
||||||
command: DaemonCommand::Stop { manager },
|
command: DaemonCommand::Stop { manager },
|
||||||
} => {
|
} => {
|
||||||
let report = run_service_command(&paths, ServiceCommand::Stop { manager })
|
let report = run_service_command(&paths, ServiceCommand::Stop { manager })
|
||||||
.context("stop geth user service")?;
|
.context("stop geth user service")?;
|
||||||
print_service_report(report, cli.json || cli.jsonl)?;
|
print_service_report(&report, cli.json || cli.jsonl)?;
|
||||||
}
|
}
|
||||||
Command::Daemon {
|
Command::Daemon {
|
||||||
command: DaemonCommand::Status { manager },
|
command: DaemonCommand::Status { manager },
|
||||||
} => {
|
} => {
|
||||||
let report = run_service_command(&paths, ServiceCommand::Status { manager })
|
let report = run_service_command(&paths, ServiceCommand::Status { manager })
|
||||||
.context("query geth user service; install it with `geth daemon install`")?;
|
.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::Daemon {
|
||||||
command: DaemonCommand::Uninstall { manager },
|
command: DaemonCommand::Uninstall { manager },
|
||||||
} => {
|
} => {
|
||||||
let report = run_service_command(&paths, ServiceCommand::Uninstall { manager })
|
let report = run_service_command(&paths, ServiceCommand::Uninstall { manager })
|
||||||
.context("uninstall geth user service")?;
|
.context("uninstall geth user service")?;
|
||||||
print_service_report(report, cli.json || cli.jsonl)?;
|
print_service_report(&report, cli.json || cli.jsonl)?;
|
||||||
}
|
}
|
||||||
Command::Daemon {
|
Command::Daemon {
|
||||||
command: DaemonCommand::Service { command },
|
command: DaemonCommand::Service { command },
|
||||||
} => {
|
} => {
|
||||||
let report =
|
let report =
|
||||||
run_service_command(&paths, command).context("manage geth user service")?;
|
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 } => {
|
Command::Wait { command } => {
|
||||||
let report = run_wait_command(&paths, command).await?;
|
let report = run_wait_command(&paths, command).await?;
|
||||||
|
|
@ -3162,18 +3259,27 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
|
||||||
manager,
|
manager,
|
||||||
bin,
|
bin,
|
||||||
start,
|
start,
|
||||||
|
allow_transient_binary,
|
||||||
} => {
|
} => {
|
||||||
geth_node::init_node(paths).context("initialize geth home before service install")?;
|
|
||||||
let manager = manager.parse::<ServiceManager>()?;
|
let manager = manager.parse::<ServiceManager>()?;
|
||||||
let executable = service_executable(bin)?;
|
geth_node::init_node(paths).context("initialize geth home before service install")?;
|
||||||
geth_node::service::install_user_service(
|
let (executable, copied_from) = service_executable(paths, bin, allow_transient_binary)?;
|
||||||
|
let mut report = geth_node::service::install_user_service(
|
||||||
paths,
|
paths,
|
||||||
ServiceInstallOptions {
|
ServiceInstallOptions {
|
||||||
manager,
|
manager,
|
||||||
executable,
|
executable,
|
||||||
start,
|
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 } => {
|
ServiceCommand::Uninstall { manager } => {
|
||||||
geth_node::service::uninstall_user_service(manager.parse::<ServiceManager>()?)?
|
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>()?)?
|
geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)?
|
||||||
}
|
}
|
||||||
ServiceCommand::Print { manager, bin } => {
|
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(
|
geth_node::service::print_user_service(
|
||||||
paths,
|
paths,
|
||||||
manager.parse::<ServiceManager>()?,
|
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> {
|
fn parse_log_line_count(value: &str) -> std::result::Result<usize, String> {
|
||||||
bin.map(Ok)
|
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)
|
.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) {
|
fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) {
|
||||||
|
|
@ -4849,40 +5030,84 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
Ok(())
|
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 {
|
if json {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::json!({
|
serde_json::to_string_pretty(&service_report_json(report))?
|
||||||
"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,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("service: {}", report.service_name);
|
println!("service: {}", report.service_name);
|
||||||
println!("manager: {}", report.manager);
|
println!("manager: {}", report.manager);
|
||||||
println!("action: {:?}", report.action);
|
println!("action: {}", report.action.as_str());
|
||||||
if let Some(state) = &report.state {
|
if let Some(state) = &report.state {
|
||||||
println!("state: {state}");
|
println!("state: {state}");
|
||||||
}
|
}
|
||||||
if let Some(path) = report.definition_path {
|
if let Some(path) = &report.definition_path {
|
||||||
println!("definition: {}", path.display());
|
println!("definition: {}", path.display());
|
||||||
}
|
}
|
||||||
if !report.commands.is_empty() {
|
if !report.commands.is_empty() {
|
||||||
println!("commands:");
|
println!("commands:");
|
||||||
for command in report.commands {
|
for command in &report.commands {
|
||||||
println!(" {}", shell_quote_command(&command));
|
println!(" {}", shell_quote_command(command));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(definition) = report.definition {
|
if let Some(definition) = &report.definition {
|
||||||
println!("definition_body:");
|
println!("definition_body:");
|
||||||
print!("{definition}");
|
print!("{definition}");
|
||||||
}
|
}
|
||||||
|
|
@ -4890,6 +5115,39 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
|
||||||
Ok(())
|
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) {
|
fn print_file_conflict(conflict: &geth_cas::FileConflict) {
|
||||||
println!("root: {}", conflict.root);
|
println!("root: {}", conflict.root);
|
||||||
println!("resource: {}", conflict.resource);
|
println!("resource: {}", conflict.resource);
|
||||||
|
|
@ -5010,7 +5268,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn common_daemon_lifecycle_commands_parse_directly() {
|
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]);
|
let parsed = Cli::try_parse_from(["geth", "daemon", command]);
|
||||||
assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}");
|
assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}");
|
||||||
}
|
}
|
||||||
|
|
@ -5022,6 +5280,48 @@ mod tests {
|
||||||
command: DaemonCommand::Run { ephemeral: true }
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,51 @@ pub enum ServiceAction {
|
||||||
Printed,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct ServiceReport {
|
pub struct ServiceReport {
|
||||||
pub manager: ServiceManager,
|
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(
|
pub fn print_user_service(
|
||||||
paths: &GethPaths,
|
paths: &GethPaths,
|
||||||
manager: ServiceManager,
|
manager: ServiceManager,
|
||||||
|
|
@ -292,6 +353,310 @@ const SYSTEMD_UNIT: &str = "geth.service";
|
||||||
const LAUNCHD_LABEL: &str = "local.geth.daemon";
|
const LAUNCHD_LABEL: &str = "local.geth.daemon";
|
||||||
const WINDOWS_TASK_NAME: &str = "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(
|
fn install_systemd_user(
|
||||||
paths: &GethPaths,
|
paths: &GethPaths,
|
||||||
executable: &Path,
|
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 {
|
fn windows_task_run_command(paths: &GethPaths, executable: &Path) -> String {
|
||||||
format!(
|
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(),
|
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() {
|
fn windows_task_uses_current_user_logon_trigger() {
|
||||||
let paths = GethPaths::from_home(r"C:\Users\Eric\AppData\Local\geth");
|
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"));
|
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("GETH_HOME="));
|
||||||
assert!(command.contains("daemon run"));
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,14 @@ on macOS, and per-user scheduled tasks on Windows. Geth does not install itself
|
||||||
as a privileged system service.
|
as a privileged system service.
|
||||||
|
|
||||||
The common service lifecycle is available directly as `geth daemon
|
The common service lifecycle is available directly as `geth daemon
|
||||||
install|start|stop|status|uninstall`; the nested service commands remain the
|
install|start|stop|status|logs|uninstall`; the nested service commands remain
|
||||||
advanced and compatibility surface. `geth daemon run --ephemeral` creates a
|
the advanced and compatibility surface. Direct installation waits for local
|
||||||
|
control readiness. A service executable resolved under a Cargo target or OS
|
||||||
|
temporary directory is copied atomically into the selected geth home's `bin/`
|
||||||
|
directory before the definition is written, unless the operator explicitly
|
||||||
|
allows the transient reference. Service log inspection reads the systemd user
|
||||||
|
journal on Linux and user-owned daemon log files on macOS and Windows.
|
||||||
|
`geth daemon run --ephemeral` creates a
|
||||||
temporary home, reports how another CLI process can select it with `--home`, and
|
temporary home, reports how another CLI process can select it with `--home`, and
|
||||||
removes it after a normal foreground shutdown. Ephemeral mode still starts the
|
removes it after a normal foreground shutdown. Ephemeral mode still starts the
|
||||||
same daemon-owned Iroh endpoint and local control stack; it is not a second
|
same daemon-owned Iroh endpoint and local control stack; it is not a second
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,26 @@ For deployment-readiness work that cuts across feature areas, see
|
||||||
- `[x]` Documentation distinguishes automated coverage from real-machine,
|
- `[x]` Documentation distinguishes automated coverage from real-machine,
|
||||||
hardware-key, relay, and privileged-interface dogfooding.
|
hardware-key, relay, and privileged-interface dogfooding.
|
||||||
|
|
||||||
- `[ ]` Add unified service-log inspection.
|
- `[x]` Add unified service-log inspection.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[ ]` One CLI command gives the platform-appropriate user-service log view
|
- `[x]` One CLI command gives the platform-appropriate user-service log view
|
||||||
or an exact recovery command on Linux, macOS, and Windows.
|
or an exact recovery command on Linux, macOS, and Windows.
|
||||||
- `[ ]` Log access remains user-scoped and does not require a system service.
|
- `[x]` Log access remains user-scoped and does not require a system service.
|
||||||
- `[ ]` Human and JSON output distinguish unavailable logs, an uninstalled
|
- `[x]` Human and JSON output distinguish unavailable logs, an uninstalled
|
||||||
service, and an installed service with no log entries.
|
service, and an installed service with no log entries.
|
||||||
|
|
||||||
|
- `[x]` Verify background-service installation readiness and durability.
|
||||||
|
Acceptance criteria:
|
||||||
|
- `[x]` Direct `geth daemon install` waits for local control readiness by
|
||||||
|
default and reports a log-inspection recovery command on timeout.
|
||||||
|
- `[x]` Service executable paths are canonical regular files; Cargo-target
|
||||||
|
and temporary binaries are copied atomically into the selected geth home by
|
||||||
|
default rather than leaving a fragile service reference.
|
||||||
|
- `[x]` An explicit override supports intentional transient development
|
||||||
|
services, and advanced install can still omit immediate startup.
|
||||||
|
- `[x]` Tests cover transient path detection/copying, file-log states, and
|
||||||
|
user-scoped generated definitions.
|
||||||
|
|
||||||
- `[ ]` Publish copy-paste installation entrypoints for release artifacts.
|
- `[ ]` Publish copy-paste installation entrypoints for release artifacts.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[ ]` Linux, macOS, and Windows installation instructions verify artifact
|
- `[ ]` Linux, macOS, and Windows installation instructions verify artifact
|
||||||
|
|
@ -360,6 +372,9 @@ control, local CAS, service installation, and written architecture decisions.
|
||||||
- Linux install targets a systemd user unit, not a system service.
|
- Linux install targets a systemd user unit, not a system service.
|
||||||
- macOS install targets a launchd user agent.
|
- macOS install targets a launchd user agent.
|
||||||
- Windows install targets a per-user scheduled task.
|
- Windows install targets a per-user scheduled task.
|
||||||
|
- Direct install verifies local daemon readiness and exposes unified logs.
|
||||||
|
- Transient build artifacts are copied into the selected geth home before a
|
||||||
|
service definition references them unless explicitly overridden.
|
||||||
- Tests verify generated definitions do not target privileged system services.
|
- Tests verify generated definitions do not target privileged system services.
|
||||||
|
|
||||||
- `[x]` GitHub CI, security, and release automation.
|
- `[x]` GitHub CI, security, and release automation.
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,16 @@ geth status
|
||||||
```
|
```
|
||||||
|
|
||||||
`daemon install` initializes the selected home if needed, installs and enables
|
`daemon install` initializes the selected home if needed, installs and enables
|
||||||
a service for the current user, and starts it. It never installs a system
|
a service for the current user, starts it, and waits for local control to become
|
||||||
service. Common lifecycle operations are direct:
|
ready. It never installs a system service. A binary launched from a Cargo target
|
||||||
|
or temporary directory is copied into the selected geth home first, preventing
|
||||||
|
later build cleanup from breaking the service. Common lifecycle operations are
|
||||||
|
direct:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
geth daemon status
|
geth daemon status
|
||||||
|
geth daemon logs
|
||||||
|
geth daemon logs --follow
|
||||||
geth daemon stop
|
geth daemon stop
|
||||||
geth daemon start
|
geth daemon start
|
||||||
geth daemon uninstall
|
geth daemon uninstall
|
||||||
|
|
@ -53,6 +58,9 @@ The older `geth daemon service ...` family remains supported for scripts and
|
||||||
advanced options. In particular, `geth daemon service install` installs without
|
advanced options. In particular, `geth daemon service install` installs without
|
||||||
starting unless `--start` is supplied.
|
starting unless `--start` is supplied.
|
||||||
|
|
||||||
|
If readiness times out, inspect `geth daemon status` and `geth daemon logs`.
|
||||||
|
Use `daemon install --no-wait` only when another process owns readiness checks.
|
||||||
|
|
||||||
### Keep state but run in the foreground
|
### Keep state but run in the foreground
|
||||||
|
|
||||||
User story: as a developer, I want persistent state and logs attached to my
|
User story: as a developer, I want persistent state and logs attached to my
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue