feat: add local doctor checks
This commit is contained in:
parent
619e8eee62
commit
12e6815f8d
7 changed files with 409 additions and 7 deletions
|
|
@ -100,6 +100,7 @@ The bootstrap implementation provides:
|
||||||
- `geth daemon service install|uninstall|start|stop|status|print`
|
- `geth daemon service install|uninstall|start|stop|status|print`
|
||||||
- `geth status`
|
- `geth status`
|
||||||
- `geth wait daemon|peer|sync --timeout-ms <ms>`
|
- `geth wait daemon|peer|sync --timeout-ms <ms>`
|
||||||
|
- `geth doctor`
|
||||||
- `geth backup create --out <dir>`
|
- `geth backup create --out <dir>`
|
||||||
- `geth backup restore <backup-dir> --target-home <dir>`
|
- `geth backup restore <backup-dir> --target-home <dir>`
|
||||||
- `geth node id`
|
- `geth node id`
|
||||||
|
|
@ -434,6 +435,14 @@ the private SSH keys.
|
||||||
`geth backup restore <backup-dir> --target-home <dir>` restores into a separate
|
`geth backup restore <backup-dir> --target-home <dir>` restores into a separate
|
||||||
empty target home for validation. It refuses to overwrite a non-empty target.
|
empty target home for validation. It refuses to overwrite a non-empty target.
|
||||||
|
|
||||||
|
## Doctor
|
||||||
|
|
||||||
|
`geth doctor` is a local operational check that works even when the daemon is
|
||||||
|
not reachable. It checks config parsing, local daemon socket health,
|
||||||
|
`ssh-keygen` availability, metadata-store readability, imported peer-card
|
||||||
|
validity, and representative peer grants when local metadata is available. Use
|
||||||
|
`--json` for scripts.
|
||||||
|
|
||||||
## Owner And Node Management
|
## Owner And Node Management
|
||||||
|
|
||||||
The intended owner setup is SSH-admin-rooted:
|
The intended owner setup is SSH-admin-rooted:
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,7 @@ pub enum Command {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: BackupCommand,
|
command: BackupCommand,
|
||||||
},
|
},
|
||||||
|
Doctor,
|
||||||
Node {
|
Node {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: NodeCommand,
|
command: NodeCommand,
|
||||||
|
|
@ -1341,6 +1342,15 @@ async fn run_inner(cli: Cli) -> Result<()> {
|
||||||
run_backup_command(&paths, command, cli.json || cli.jsonl)
|
run_backup_command(&paths, command, cli.json || cli.jsonl)
|
||||||
.context("run backup command")?;
|
.context("run backup command")?;
|
||||||
}
|
}
|
||||||
|
Command::Doctor => {
|
||||||
|
let report = geth_node::doctor::run_doctor(&paths)
|
||||||
|
.await
|
||||||
|
.context("run doctor")?;
|
||||||
|
print_doctor_report(&report, cli.json || cli.jsonl)?;
|
||||||
|
if !report.ok {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Command::Ssh {
|
Command::Ssh {
|
||||||
command:
|
command:
|
||||||
SshCommand::Proxy {
|
SshCommand::Proxy {
|
||||||
|
|
@ -2241,6 +2251,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
| Command::Init { .. }
|
| Command::Init { .. }
|
||||||
| Command::Daemon { .. }
|
| Command::Daemon { .. }
|
||||||
| Command::Backup { .. }
|
| Command::Backup { .. }
|
||||||
|
| Command::Doctor
|
||||||
| Command::Wait { .. } => {
|
| Command::Wait { .. } => {
|
||||||
bail!("command is handled directly")
|
bail!("command is handled directly")
|
||||||
}
|
}
|
||||||
|
|
@ -2316,6 +2327,29 @@ fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) ->
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_doctor_report(report: &geth_node::doctor::DoctorReport, json: bool) -> Result<()> {
|
||||||
|
if json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&serde_json::json!({
|
||||||
|
"type": "doctor",
|
||||||
|
"ok": report.ok,
|
||||||
|
"checks": report.checks,
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("doctor: {}", if report.ok { "ok" } else { "failed" });
|
||||||
|
for check in &report.checks {
|
||||||
|
println!("{:?}\t{}\t{}", check.status, check.code, check.message);
|
||||||
|
if let Some(hint) = &check.hint {
|
||||||
|
println!("hint\t{}\t{}", check.code, hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_wait_command(paths: &GethPaths, command: WaitCommand) -> Result<WaitReport> {
|
async fn run_wait_command(paths: &GethPaths, command: WaitCommand) -> Result<WaitReport> {
|
||||||
match command {
|
match command {
|
||||||
WaitCommand::Daemon {
|
WaitCommand::Daemon {
|
||||||
|
|
|
||||||
338
crates/geth-node/src/doctor.rs
Normal file
338
crates/geth-node/src/doctor.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
use crate::{NodeError, send_control};
|
||||||
|
use geth_auth::AuthOp;
|
||||||
|
use geth_config::{GethConfig, GethPaths};
|
||||||
|
use geth_control::{ControlRequest, ControlResponse};
|
||||||
|
use geth_discovery::PeerCard;
|
||||||
|
use geth_store::Store;
|
||||||
|
use geth_types::PrincipalId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DoctorReport {
|
||||||
|
pub ok: bool,
|
||||||
|
pub checks: Vec<DoctorCheck>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DoctorCheck {
|
||||||
|
pub code: String,
|
||||||
|
pub status: DoctorStatus,
|
||||||
|
pub message: String,
|
||||||
|
pub hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum DoctorStatus {
|
||||||
|
Ok,
|
||||||
|
Warn,
|
||||||
|
Fail,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_doctor(paths: &GethPaths) -> Result<DoctorReport, NodeError> {
|
||||||
|
let mut checks = Vec::new();
|
||||||
|
check_config(paths, &mut checks);
|
||||||
|
check_daemon(paths, &mut checks).await;
|
||||||
|
check_ssh_keygen(&mut checks);
|
||||||
|
check_store_metadata(paths, &mut checks);
|
||||||
|
let ok = checks
|
||||||
|
.iter()
|
||||||
|
.all(|check| check.status != DoctorStatus::Fail);
|
||||||
|
Ok(DoctorReport { ok, checks })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_config(paths: &GethPaths, checks: &mut Vec<DoctorCheck>) {
|
||||||
|
match GethConfig::load(&paths.config_file()) {
|
||||||
|
Ok(_) => checks.push(ok(
|
||||||
|
"config-ok",
|
||||||
|
format!("config is readable: {}", paths.config_file().display()),
|
||||||
|
)),
|
||||||
|
Err(error) => checks.push(fail(
|
||||||
|
"config-invalid",
|
||||||
|
format!("config is invalid: {error}"),
|
||||||
|
"fix config.toml or move it aside and rerun `geth init` to regenerate defaults",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_daemon(paths: &GethPaths, checks: &mut Vec<DoctorCheck>) {
|
||||||
|
if !paths.socket_path().exists() {
|
||||||
|
checks.push(fail(
|
||||||
|
"daemon-not-running",
|
||||||
|
format!("daemon socket is absent: {}", paths.socket_path().display()),
|
||||||
|
"start the daemon with `geth daemon run` or the user service command for your platform",
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match send_control(paths, ControlRequest::Status).await {
|
||||||
|
Ok(ControlResponse::Status(status)) => checks.push(ok(
|
||||||
|
"daemon-ok",
|
||||||
|
format!(
|
||||||
|
"daemon responded as {} with endpoint {}",
|
||||||
|
status.node_id,
|
||||||
|
status.endpoint_id.as_deref().unwrap_or("not-started")
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Ok(other) => checks.push(fail(
|
||||||
|
"daemon-unexpected-response",
|
||||||
|
format!("daemon returned unexpected response: {other:?}"),
|
||||||
|
"restart the daemon and rerun `geth doctor`",
|
||||||
|
)),
|
||||||
|
Err(error) => checks.push(fail(
|
||||||
|
"daemon-stale-socket",
|
||||||
|
format!(
|
||||||
|
"daemon socket exists but control connection failed: {}",
|
||||||
|
error
|
||||||
|
),
|
||||||
|
"stop any stale daemon, remove the socket if no daemon is running, then start `geth daemon run`",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_ssh_keygen(checks: &mut Vec<DoctorCheck>) {
|
||||||
|
if std::process::Command::new("ssh-keygen")
|
||||||
|
.arg("-?")
|
||||||
|
.output()
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
checks.push(ok(
|
||||||
|
"ssh-keygen-ok",
|
||||||
|
"ssh-keygen is available for SSH signature and certificate workflows",
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
checks.push(fail(
|
||||||
|
"ssh-keygen-missing",
|
||||||
|
"ssh-keygen is not available on PATH",
|
||||||
|
"install OpenSSH client tools before using admin signing, cert approval, or KRL export workflows",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_store_metadata(paths: &GethPaths, checks: &mut Vec<DoctorCheck>) {
|
||||||
|
if !paths.metadata_db().exists() {
|
||||||
|
checks.push(fail(
|
||||||
|
"store-missing",
|
||||||
|
format!(
|
||||||
|
"metadata store is absent: {}",
|
||||||
|
paths.metadata_db().display()
|
||||||
|
),
|
||||||
|
"run `geth init` before starting the daemon or creating resources",
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = match Store::open(&paths.metadata_db()) {
|
||||||
|
Ok(store) => store,
|
||||||
|
Err(error) => {
|
||||||
|
checks.push(fail(
|
||||||
|
"store-open-failed",
|
||||||
|
format!("metadata store could not be opened: {error}"),
|
||||||
|
"restore from backup or inspect the SQLite file before continuing",
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checks.push(ok(
|
||||||
|
"store-ok",
|
||||||
|
format!(
|
||||||
|
"metadata store is readable: {}",
|
||||||
|
paths.metadata_db().display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
check_peer_cards(&store, checks);
|
||||||
|
check_peer_grants(&store, checks);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_peer_cards(store: &Store, checks: &mut Vec<DoctorCheck>) {
|
||||||
|
let peers = match store.list_peer_cards() {
|
||||||
|
Ok(peers) => peers,
|
||||||
|
Err(error) => {
|
||||||
|
checks.push(fail(
|
||||||
|
"peer-card-read-failed",
|
||||||
|
format!("could not read peer cards: {error}"),
|
||||||
|
"inspect the metadata store or restore from backup",
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if peers.is_empty() {
|
||||||
|
checks.push(warn(
|
||||||
|
"peer-card-none",
|
||||||
|
"no imported peer cards are available",
|
||||||
|
"exchange peer cards with `geth peer export` and `geth peer import` before remote operations",
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut invalid = Vec::new();
|
||||||
|
for peer in peers {
|
||||||
|
match serde_json::from_str::<PeerCard>(&peer.card_json)
|
||||||
|
.map_err(NodeError::from)
|
||||||
|
.and_then(|card| card.validate_candidate().map_err(NodeError::from))
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) => invalid.push(format!("{}: {error}", peer.peer_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if invalid.is_empty() {
|
||||||
|
checks.push(ok(
|
||||||
|
"peer-cards-ok",
|
||||||
|
"imported peer cards are valid candidates",
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
checks.push(warn(
|
||||||
|
"peer-card-invalid",
|
||||||
|
format!(
|
||||||
|
"some imported peer cards are invalid: {}",
|
||||||
|
invalid.join("; ")
|
||||||
|
),
|
||||||
|
"re-import a fresh signed peer card from the affected peer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_peer_grants(store: &Store, checks: &mut Vec<DoctorCheck>) {
|
||||||
|
let peers = match store.list_peer_cards() {
|
||||||
|
Ok(peers) if !peers.is_empty() => peers,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let resources = match store.list_resources() {
|
||||||
|
Ok(resources) if !resources.is_empty() => resources,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let auth_ops = match store.list_auth_ops() {
|
||||||
|
Ok(ops) => ops
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|stored| serde_json::from_str::<AuthOp>(&stored.op_json).ok())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(error) => {
|
||||||
|
checks.push(fail(
|
||||||
|
"auth-op-read-failed",
|
||||||
|
format!("could not read auth operations: {error}"),
|
||||||
|
"inspect the metadata store or restore from backup",
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for peer in peers.iter().take(4) {
|
||||||
|
for resource in resources.iter().take(6) {
|
||||||
|
let Some(capability) = representative_capability(&resource.kind) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let explanation = geth_auth::explain_auth_ops(
|
||||||
|
&auth_ops
|
||||||
|
.iter()
|
||||||
|
.filter(|op| op.resource.as_str() == resource.resource_id)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
PrincipalId::new(peer.peer_id.clone()),
|
||||||
|
resource.resource_id.clone().into(),
|
||||||
|
capability.into(),
|
||||||
|
);
|
||||||
|
if !explanation.allowed {
|
||||||
|
missing.push(format!(
|
||||||
|
"{} lacks {} on {}",
|
||||||
|
peer.peer_id, capability, resource.resource_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
checks.push(ok(
|
||||||
|
"peer-grants-ok",
|
||||||
|
"imported peers have representative grants for known resources",
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
checks.push(warn(
|
||||||
|
"peer-grants-missing",
|
||||||
|
format!("missing representative grants: {}", missing.join("; ")),
|
||||||
|
"use `geth node grant` or `geth auth grant` for the resource/capability needed by the peer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn representative_capability(kind: &str) -> Option<&'static str> {
|
||||||
|
match kind {
|
||||||
|
"cas" => Some("cas.fetch"),
|
||||||
|
"kv" => Some("kv.sync"),
|
||||||
|
"db" => Some("db.sync"),
|
||||||
|
"document" => Some("document.sync"),
|
||||||
|
"pubsub" => Some("pubsub.subscribe"),
|
||||||
|
"pipe" => Some("pipe.connect"),
|
||||||
|
"ssh-proxy" => Some("ssh_proxy.connect"),
|
||||||
|
"overlay" => Some("overlay.route"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok(code: impl Into<String>, message: impl Into<String>) -> DoctorCheck {
|
||||||
|
DoctorCheck {
|
||||||
|
code: code.into(),
|
||||||
|
status: DoctorStatus::Ok,
|
||||||
|
message: message.into(),
|
||||||
|
hint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn(
|
||||||
|
code: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
hint: impl Into<String>,
|
||||||
|
) -> DoctorCheck {
|
||||||
|
DoctorCheck {
|
||||||
|
code: code.into(),
|
||||||
|
status: DoctorStatus::Warn,
|
||||||
|
message: message.into(),
|
||||||
|
hint: Some(hint.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(
|
||||||
|
code: impl Into<String>,
|
||||||
|
message: impl Into<String>,
|
||||||
|
hint: impl Into<String>,
|
||||||
|
) -> DoctorCheck {
|
||||||
|
DoctorCheck {
|
||||||
|
code: code.into(),
|
||||||
|
status: DoctorStatus::Fail,
|
||||||
|
message: message.into(),
|
||||||
|
hint: Some(hint.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn doctor_reports_missing_daemon_and_store_before_init() {
|
||||||
|
let home = tempfile::tempdir().expect("home");
|
||||||
|
let paths = GethPaths::from_home(home.path());
|
||||||
|
let report = run_doctor(&paths).await.expect("doctor");
|
||||||
|
assert!(!report.ok);
|
||||||
|
assert!(has_code(&report, "daemon-not-running"));
|
||||||
|
assert!(has_code(&report, "store-missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn doctor_reports_bad_config_and_stale_socket() {
|
||||||
|
let home = tempfile::tempdir().expect("home");
|
||||||
|
let paths = GethPaths::from_home(home.path());
|
||||||
|
paths.ensure_base_dirs().expect("dirs");
|
||||||
|
std::fs::write(paths.config_file(), "[iroh]\nrelay_mode = 1").expect("bad config");
|
||||||
|
std::fs::write(paths.socket_path(), "stale").expect("socket placeholder");
|
||||||
|
let report = run_doctor(&paths).await.expect("doctor");
|
||||||
|
assert!(!report.ok);
|
||||||
|
assert!(has_code(&report, "config-invalid"));
|
||||||
|
assert!(has_code(&report, "daemon-stale-socket"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_code(report: &DoctorReport, code: &str) -> bool {
|
||||||
|
report.checks.iter().any(|check| check.code == code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
mod daemon;
|
mod daemon;
|
||||||
|
pub mod doctor;
|
||||||
mod peer_client;
|
mod peer_client;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,25 @@ fn wait_daemon_reports_json_timeout_without_daemon() {
|
||||||
assert!(report["attempts"].as_u64().expect("attempts") >= 1);
|
assert!(report["attempts"].as_u64().expect("attempts") >= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doctor_reports_json_failures_without_daemon() {
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let output = run_geth(home.path(), &["--json", "doctor"]);
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let report =
|
||||||
|
serde_json::from_slice::<serde_json::Value>(&output.stdout).expect("decode doctor report");
|
||||||
|
assert_eq!(report["type"], "doctor");
|
||||||
|
assert_eq!(report["ok"], false);
|
||||||
|
let codes = report["checks"]
|
||||||
|
.as_array()
|
||||||
|
.expect("checks")
|
||||||
|
.iter()
|
||||||
|
.map(|check| check["code"].as_str().expect("code"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(codes.contains(&"daemon-not-running"));
|
||||||
|
assert!(codes.contains(&"store-missing"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_ping_uses_daemon_owned_iroh_endpoint() {
|
fn peer_ping_uses_daemon_owned_iroh_endpoint() {
|
||||||
if skip_iroh_integration_tests() {
|
if skip_iroh_integration_tests() {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ The following command families are intended to be stable automation surfaces:
|
||||||
- `geth daemon run`
|
- `geth daemon run`
|
||||||
- `geth daemon service install|uninstall|start|stop|status|print`
|
- `geth daemon service install|uninstall|start|stop|status|print`
|
||||||
- `geth status`
|
- `geth status`
|
||||||
|
- `geth doctor`
|
||||||
- `geth node id`
|
- `geth node id`
|
||||||
- `geth backup create|restore`
|
- `geth backup create|restore`
|
||||||
- `geth node list|rename|revoke|grant|revoke-grant|endpoint-add|endpoint-revoke`
|
- `geth node list|rename|revoke|grant|revoke-grant|endpoint-add|endpoint-revoke`
|
||||||
|
|
|
||||||
|
|
@ -283,15 +283,15 @@ Goal: make production failures diagnosable from the CLI and logs.
|
||||||
- `[x]` Iroh relay/local-discovery state is visible without exposing
|
- `[x]` Iroh relay/local-discovery state is visible without exposing
|
||||||
unrelated config secrets.
|
unrelated config secrets.
|
||||||
|
|
||||||
- `[ ]` Add `geth doctor`.
|
- `[x]` Add `geth doctor`.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[ ]` Doctor detects daemon not running.
|
- `[x]` Doctor detects daemon not running.
|
||||||
- `[ ]` Doctor detects stale local socket.
|
- `[x]` Doctor detects stale local socket.
|
||||||
- `[ ]` Doctor detects bad config.
|
- `[x]` Doctor detects bad config.
|
||||||
- `[ ]` Doctor detects missing `ssh-keygen`.
|
- `[x]` Doctor detects missing `ssh-keygen`.
|
||||||
- `[ ]` Doctor explains missing grants and endpoint mismatches when enough
|
- `[x]` Doctor explains missing grants and endpoint mismatches when enough
|
||||||
local metadata exists.
|
local metadata exists.
|
||||||
- `[ ]` Doctor has JSON output for scripts.
|
- `[x]` Doctor has JSON output for scripts.
|
||||||
|
|
||||||
## Phase 9: Packaging, Upgrade, And Release Discipline
|
## Phase 9: Packaging, Upgrade, And Release Discipline
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue