From efb9438fdc4cf06e6302fb58392ebf1b152ffbea Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Wed, 6 May 2026 15:18:43 +0200 Subject: [PATCH] feat(cli): add zeph gonka doctor diagnostic subcommand and live testnet test Adds `zeph gonka doctor` subcommand (feature-gated under `gonka`) that reads vault keys, derives and verifies the on-chain address, and probes each configured gonka node concurrently via signed POST to /chat/completions, reporting per-node HTTP status and latency. Detects clock skew on 401 responses by comparing the Date response header against local time. Adds crates/zeph-llm/tests/gonka_live.rs: a single #[ignore] integration test that round-trips a 1-token completion against a live gonka node; skips gracefully when ZEPH_GONKA_PRIVATE_KEY is not set. Closes #3614. --- CHANGELOG.md | 6 + book/src/guides/gonka.md | 10 + crates/zeph-llm/tests/gonka_live.rs | 62 +++ src/cli.rs | 21 + src/commands/doctor.rs | 16 +- src/commands/gonka.rs | 579 ++++++++++++++++++++++++++++ src/commands/mod.rs | 2 + src/runner.rs | 9 + 8 files changed, 701 insertions(+), 4 deletions(-) create mode 100644 crates/zeph-llm/tests/gonka_live.rs create mode 100644 src/commands/gonka.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d21d5867e..f0e7d1784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- feat(cli): `zeph gonka doctor` diagnostic subcommand — checks vault key resolution, signer + construction, and per-node HTTP reachability with signed probes. Reports `[OK]`, `[WARN]`, or + `[FAIL]` per check; detects 401 clock skew via `Date` header. `--json` flag emits a structured + JSON envelope. Requires `gonka` feature. Also adds an `#[ignore]` live-testnet integration test + in `crates/zeph-llm/tests/gonka_live.rs`. (#3614) + - feat(core): `SpeculationEngine.try_dispatch` wired into two activation paths. SSE decoding path: `claude_sse_to_tool_stream` emits `ToolBlockStart` at `content_block_start` so `SpeculativeStreamDrainer` can populate `tool_meta` before `InputJsonDelta` events arrive; when diff --git a/book/src/guides/gonka.md b/book/src/guides/gonka.md index 882d3b3de..9d8a3ae14 100644 --- a/book/src/guides/gonka.md +++ b/book/src/guides/gonka.md @@ -75,6 +75,16 @@ address = "gonka1..." ## Troubleshooting +Run the built-in diagnostic tool to check credentials and node reachability: + +```bash +zeph gonka doctor +# or for machine-readable JSON output: +zeph gonka doctor --json +``` + +The doctor prints `[OK]`, `[WARN]`, or `[FAIL]` for each check: vault key resolution, signer construction, and per-node HTTP probes with latency. Exit code is 0 on success, 1 on failures. + | Symptom | Cause | Fix | |---------|-------|-----| | 401 / signature error | Invalid key format or address mismatch | Verify `ZEPH_GONKA_PRIVATE_KEY` is hex-encoded secp256k1; confirm address matches key | diff --git a/crates/zeph-llm/tests/gonka_live.rs b/crates/zeph-llm/tests/gonka_live.rs new file mode 100644 index 000000000..13c00618d --- /dev/null +++ b/crates/zeph-llm/tests/gonka_live.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Live testnet integration test for the Gonka provider. +//! +//! Skipped by default — requires a running Gonka testnet node and a funded wallet. +//! Run with: +//! ```shell +//! ZEPH_GONKA_PRIVATE_KEY= cargo nextest run -p zeph-llm -- --ignored gonka_live +//! ``` + +#[cfg(feature = "gonka")] +mod live { + use std::sync::Arc; + use std::time::Duration; + + use zeph_llm::gonka::endpoints::{EndpointPool, GonkaEndpoint}; + use zeph_llm::gonka::{GonkaProvider, RequestSigner}; + use zeph_llm::provider::{LlmProvider, Message, Role}; + + #[tokio::test] + #[ignore = "requires ZEPH_GONKA_PRIVATE_KEY env var and live Gonka testnet access"] + async fn gonka_live_chat_round_trip() { + let priv_key = match std::env::var("ZEPH_GONKA_PRIVATE_KEY") { + Ok(k) if !k.is_empty() => k, + _ => { + eprintln!("ZEPH_GONKA_PRIVATE_KEY not set, skipping"); + return; + } + }; + + let node_url = std::env::var("ZEPH_GONKA_NODE_URL") + .unwrap_or_else(|_| "http://node1.gonka.ai:8000".into()); + + let signer = Arc::new( + RequestSigner::from_hex(&priv_key, "gonka").expect("valid secp256k1 private key"), + ); + + let pool = Arc::new( + EndpointPool::new(vec![GonkaEndpoint { + base_url: node_url.clone(), + address: signer.address().to_owned(), + }]) + .expect("non-empty pool"), + ); + + let provider = + GonkaProvider::new(signer, pool, "gpt-4o", 16, None, Duration::from_secs(30)); + + let messages = vec![Message::from_legacy( + Role::User, + "Say hello in one word.".to_owned(), + )]; + + let response = provider + .chat(&messages) + .await + .expect("chat should succeed against live testnet"); + + assert!(!response.is_empty(), "response must not be empty"); + } +} diff --git a/src/cli.rs b/src/cli.rs index ef85cba9a..a7a021528 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -397,6 +397,12 @@ pub(crate) enum Command { #[arg(long, default_value = "5")] mcp_timeout_secs: u64, }, + /// Gonka network diagnostics and credential checks + #[cfg(feature = "gonka")] + Gonka { + #[command(subcommand)] + command: GonkaCommand, + }, /// Test notification channels (sends a test notification via enabled channels) Notify { #[command(subcommand)] @@ -665,6 +671,21 @@ pub(crate) enum RouterCommand { }, } +/// Gonka network subcommands. +#[cfg(feature = "gonka")] +#[derive(Subcommand)] +pub(crate) enum GonkaCommand { + /// Run Gonka connectivity and credential diagnostics + Doctor { + /// Emit results as JSON (`schema_version` = 1) + #[arg(long)] + json: bool, + /// Timeout in seconds for node probe requests + #[arg(long, default_value = "10")] + timeout_secs: u64, + }, +} + /// Notification management subcommands. #[derive(Subcommand)] pub(crate) enum NotifyCommand { diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 8f336f9d0..b7020c74e 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -54,7 +54,7 @@ pub(crate) struct CheckResult { } impl CheckResult { - fn ok(name: impl Into, detail: impl Into, elapsed_ms: u64) -> Self { + pub(crate) fn ok(name: impl Into, detail: impl Into, elapsed_ms: u64) -> Self { Self { name: name.into(), status: CheckStatus::Ok, @@ -63,7 +63,11 @@ impl CheckResult { } } - fn warn(name: impl Into, detail: impl Into, elapsed_ms: u64) -> Self { + pub(crate) fn warn( + name: impl Into, + detail: impl Into, + elapsed_ms: u64, + ) -> Self { Self { name: name.into(), status: CheckStatus::Warn, @@ -72,7 +76,11 @@ impl CheckResult { } } - fn fail(name: impl Into, detail: impl Into, elapsed_ms: u64) -> Self { + pub(crate) fn fail( + name: impl Into, + detail: impl Into, + elapsed_ms: u64, + ) -> Self { Self { name: name.into(), status: CheckStatus::Fail, @@ -421,7 +429,7 @@ async fn check_llm_provider( if entry.provider_type == ProviderKind::Gonka { return CheckResult::ok( &check_name, - "gonka (not yet implemented)", + "gonka (use `zeph gonka doctor` for detailed diagnostics)", elapsed_ms(start), ); } diff --git a/src/commands/gonka.rs b/src/commands/gonka.rs new file mode 100644 index 000000000..97b3ee39e --- /dev/null +++ b/src/commands/gonka.rs @@ -0,0 +1,579 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! `zeph gonka doctor` — Gonka network connectivity and credential diagnostics. +//! +//! Checks vault key resolution, signer construction, and per-node HTTP reachability. +//! Exit code is 0 if no failures, 1 otherwise. + +use std::collections::HashSet; +use std::io; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use tokio::sync::RwLock; +use tokio::task::JoinSet; +use tracing::Instrument as _; +use zeph_config::ProviderKind; +use zeph_core::redact::scrub_content; +use zeph_core::vault::{AgeVaultProvider, ArcAgeVaultProvider, VaultProvider}; +use zeph_llm::gonka::RequestSigner; + +use crate::commands::doctor::{CheckResult, CheckStatus, DoctorReport}; + +fn elapsed_ms(start: Instant) -> u64 { + u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +fn finish(report: &DoctorReport, json: bool) -> anyhow::Result { + let mut out = io::stdout(); + if json { + report.render_json(&mut out)?; + } else { + report.render_plain(&mut out)?; + } + let has_fail = report.results.iter().any(|r| r.status == CheckStatus::Fail); + Ok(i32::from(has_fail)) +} + +/// Attempt to build a vault provider and resolve the gonka secrets. +/// +/// Returns `(private_key_result, private_key_opt, address_result, address_opt)`. +/// `address_opt` is the raw vault-stored bech32 address, used to verify against the derived one. +async fn resolve_vault_secrets( + config: &zeph_core::config::Config, +) -> (CheckResult, Option, CheckResult, Option) { + let _span = tracing::info_span!("cli.gonka.doctor.vault").entered(); + let vault_args = crate::bootstrap::parse_vault_args(config, None, None, None); + + let vault: Box = match vault_args.backend.as_str() { + "age" => { + let (Some(key), Some(path)) = ( + vault_args.key_path.as_deref(), + vault_args.vault_path.as_deref(), + ) else { + let start = Instant::now(); + let r = CheckResult::fail( + "gonka.vault.private_key", + "age vault paths not configured; run `zeph vault init`", + elapsed_ms(start), + ); + let addr_r = + CheckResult::fail("gonka.vault.address", "skipped (vault unavailable)", 0); + return (r, None, addr_r, None); + }; + match AgeVaultProvider::new(Path::new(key), Path::new(path)) { + Ok(p) => Box::new(ArcAgeVaultProvider(Arc::new(RwLock::new(p)))), + Err(e) => { + let start = Instant::now(); + let r = CheckResult::fail( + "gonka.vault.private_key", + format!("vault open failed: {e}; run `zeph vault init`"), + elapsed_ms(start), + ); + let addr_r = + CheckResult::fail("gonka.vault.address", "skipped (vault unavailable)", 0); + return (r, None, addr_r, None); + } + } + } + #[cfg(feature = "env-vault")] + "env" => Box::new(zeph_core::vault::EnvVaultProvider), + _ => { + let start = Instant::now(); + let r = CheckResult::warn( + "gonka.vault.private_key", + format!( + "unknown vault backend '{}'; cannot resolve secrets", + vault_args.backend + ), + elapsed_ms(start), + ); + let addr_r = CheckResult::warn("gonka.vault.address", "skipped (unknown backend)", 0); + return (r, None, addr_r, None); + } + }; + + // Resolve private key + let start_key = Instant::now(); + let priv_key_opt = vault + .get_secret("ZEPH_GONKA_PRIVATE_KEY") + .await + .ok() + .flatten(); + let priv_key_result = if priv_key_opt.is_some() { + CheckResult::ok( + "gonka.vault.private_key", + "ZEPH_GONKA_PRIVATE_KEY present", + elapsed_ms(start_key), + ) + } else { + CheckResult::fail( + "gonka.vault.private_key", + "ZEPH_GONKA_PRIVATE_KEY not found in vault; run `zeph vault set ZEPH_GONKA_PRIVATE_KEY `", + elapsed_ms(start_key), + ) + }; + + // Resolve address (optional) + let start_addr = Instant::now(); + let addr_opt = vault.get_secret("ZEPH_GONKA_ADDRESS").await.ok().flatten(); + let addr_result = if addr_opt.is_some() { + CheckResult::ok( + "gonka.vault.address", + "ZEPH_GONKA_ADDRESS present", + elapsed_ms(start_addr), + ) + } else { + CheckResult::warn( + "gonka.vault.address", + "ZEPH_GONKA_ADDRESS not set; derived address will be used", + elapsed_ms(start_addr), + ) + }; + + (priv_key_result, priv_key_opt, addr_result, addr_opt) +} + +/// Build a probe request body for `/chat/completions`. +fn build_probe_body(model: &str) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "model": model, + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 1, + })) + .expect("static JSON body serialization never fails") +} + +/// Probe a single Gonka node with a signed POST to `/chat/completions`. +/// +/// Returns a `CheckResult` with HTTP status and latency. On 401 responses, +/// checks the `Date` response header for clock skew > 30 seconds. +/// The caller instruments this future with a tracing span. +async fn probe_node( + check_name: String, + node_url: String, + node_label: String, + model: String, + signer: Arc, + client: Arc, + timeout_secs: u64, +) -> (String, CheckResult) { + let start = Instant::now(); + let body_bytes = build_probe_body(&model); + + let timestamp_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + let sig = match signer.sign(&body_bytes, timestamp_ns, signer.address()) { + Ok(s) => s, + Err(e) => { + return ( + check_name.clone(), + CheckResult::fail( + &check_name, + scrub_content(&format!("{node_label}: signing failed: {e}")).into_owned(), + elapsed_ms(start), + ), + ); + } + }; + + let url = format!("{}/chat/completions", node_url.trim_end_matches('/')); + + let request = client + .post(&url) + .header("Content-Type", "application/json") + .header("X-Timestamp", timestamp_ns.to_string()) + .header("X-Signature", &sig) + .header("X-Address", signer.address()) + .body(body_bytes); + + let result = tokio::time::timeout(Duration::from_secs(timeout_secs), request.send()).await; + let check_result = classify_probe_result(result, &check_name, &node_label, timeout_secs, start); + (check_name, check_result) +} + +/// Turn a raw reqwest result into a `CheckResult`. +fn classify_probe_result( + result: Result, tokio::time::error::Elapsed>, + check_name: &str, + node_label: &str, + timeout_secs: u64, + start: Instant, +) -> CheckResult { + match result { + Err(_) => CheckResult::fail( + check_name, + format!("{node_label}: timed out after {timeout_secs}s"), + elapsed_ms(start), + ), + Ok(Err(e)) => { + let msg = if e.is_connect() { + format!("{node_label}: connection refused or DNS resolution failed") + } else { + format!( + "{node_label}: request error: {}", + scrub_content(&e.to_string()) + ) + }; + CheckResult::fail(check_name, msg, elapsed_ms(start)) + } + Ok(Ok(resp)) => { + let status = resp.status(); + let headers = resp.headers(); + classify_http_response(status, headers, check_name, node_label, start) + } + } +} + +/// Classify an HTTP response status + headers into a `CheckResult`. +fn classify_http_response( + status: reqwest::StatusCode, + headers: &reqwest::header::HeaderMap, + check_name: &str, + node_label: &str, + start: Instant, +) -> CheckResult { + let latency = elapsed_ms(start); + + if status.is_success() { + return CheckResult::ok( + check_name, + format!("{node_label}: HTTP {} ({latency} ms)", status.as_u16()), + latency, + ); + } + + if status.as_u16() == 401 { + if let Some(skew_msg) = headers + .get(reqwest::header::DATE) + .and_then(|v| v.to_str().ok()) + .and_then(detect_clock_skew) + { + return CheckResult::warn( + check_name, + format!("{node_label}: auth rejected — {skew_msg}"), + latency, + ); + } + return CheckResult::fail( + check_name, + format!("{node_label}: HTTP 401 auth rejected (check private key or node address)"), + latency, + ); + } + + CheckResult::warn( + check_name, + format!("{node_label}: HTTP {} ({latency} ms)", status.as_u16()), + latency, + ) +} + +/// Parse an HTTP `Date` header and return a clock skew description if |delta| > 30s. +fn detect_clock_skew(date_str: &str) -> Option { + let server_time = chrono::DateTime::parse_from_rfc2822(date_str) + .ok() + .map(|dt| dt.timestamp())?; + let local_time = chrono::Utc::now().timestamp(); + let delta = local_time - server_time; + if delta.unsigned_abs() <= 30 { + return None; + } + let direction = if delta > 0 { "ahead of" } else { "behind" }; + Some(format!( + "clock skew detected: local is {}s {direction} server", + delta.unsigned_abs() + )) +} + +/// Probe all nodes from all gonka providers concurrently, deduplicating by URL. +/// +/// Uses a `JoinSet` so all probes run in parallel. Results are re-ordered by +/// `node_index` before being appended to `results`. +async fn probe_all_nodes( + gonka_providers: &[&zeph_config::ProviderEntry], + signer: Arc, + client: Arc, + timeout_secs: u64, + results: &mut Vec, +) { + let mut seen_urls: HashSet = HashSet::new(); + let mut set: JoinSet<(usize, String, CheckResult)> = JoinSet::new(); + let mut node_index = 0usize; + + for entry in gonka_providers { + if entry.gonka_nodes.is_empty() { + let start = Instant::now(); + let name = entry.name.as_deref().unwrap_or(""); + results.push(CheckResult::warn( + format!("gonka.node.{name}"), + "provider has no gonka_nodes configured", + elapsed_ms(start), + )); + continue; + } + + let model = entry.model.as_deref().unwrap_or("gpt-4o").to_owned(); + + for node in &entry.gonka_nodes { + if !seen_urls.insert(node.url.clone()) { + continue; + } + let idx = node_index; + node_index += 1; + + let label = node.name.clone().unwrap_or_else(|| node.url.clone()); + let check_name = format!("gonka.node[{idx}]"); + let node_url = node.url.clone(); + let span_url = node_url.clone(); + let signer = Arc::clone(&signer); + let client = Arc::clone(&client); + let model = model.clone(); + + set.spawn( + async move { + let (name, result) = probe_node( + check_name, + node_url, + label, + model, + signer, + client, + timeout_secs, + ) + .await; + (idx, name, result) + } + .instrument(tracing::info_span!("cli.gonka.doctor.probe", node = %span_url)), + ); + } + } + + // Collect and re-order by node_index for deterministic output + let mut indexed: Vec<(usize, String, CheckResult)> = set.join_all().await; + indexed.sort_by_key(|(i, _, _)| *i); + results.extend(indexed.into_iter().map(|(_, _, r)| r)); +} + +/// Run Gonka doctor diagnostics. +/// +/// # Errors +/// +/// Returns an error if config parsing or I/O fails at the top level. +#[allow(clippy::too_many_lines)] +pub(crate) async fn run_gonka_doctor( + config_path: &Path, + json: bool, + timeout_secs: u64, +) -> anyhow::Result { + let _span = tracing::info_span!("cli.gonka.doctor").entered(); + let total_start = Instant::now(); + let mut results: Vec = Vec::new(); + + // 1. Config parse + find gonka providers + let start = Instant::now(); + let config = match zeph_core::config::Config::load(config_path) { + Ok(c) => { + results.push(CheckResult::ok( + "gonka.config", + format!("loaded {}", config_path.display()), + elapsed_ms(start), + )); + c + } + Err(e) => { + results.push(CheckResult::fail( + "gonka.config", + format!("failed to load config: {e}"), + elapsed_ms(start), + )); + let report = DoctorReport { + results, + elapsed_ms: elapsed_ms(total_start), + }; + return finish(&report, json); + } + }; + + let gonka_providers: Vec<&zeph_config::ProviderEntry> = config + .llm + .providers + .iter() + .filter(|e| e.provider_type == ProviderKind::Gonka) + .collect(); + + if gonka_providers.is_empty() { + let start = Instant::now(); + results.push(CheckResult::warn( + "gonka.config", + "no [[llm.providers]] entries with type=\"gonka\" found; nothing to probe", + elapsed_ms(start), + )); + let report = DoctorReport { + results, + elapsed_ms: elapsed_ms(total_start), + }; + return finish(&report, json); + } + + // 2 + 3. Vault: resolve private key and optional address + let (priv_key_result, priv_key_opt, addr_result, vault_addr_opt) = + resolve_vault_secrets(&config).await; + let priv_key_failed = priv_key_result.status == CheckStatus::Fail; + results.push(priv_key_result); + results.push(addr_result); + + if priv_key_failed { + let report = DoctorReport { + results, + elapsed_ms: elapsed_ms(total_start), + }; + return finish(&report, json); + } + + let priv_key = priv_key_opt.expect("priv_key_opt is Some when !priv_key_failed"); + + // 4. Signer construction + optional address mismatch check + let chain_prefix = gonka_providers.first().map_or_else( + || "gonka".to_owned(), + |e| e.effective_gonka_chain_prefix().to_owned(), + ); + + let start = Instant::now(); + let signer = match RequestSigner::from_hex(&priv_key, &chain_prefix) { + Ok(s) => { + results.push(CheckResult::ok( + "gonka.signer", + format!("derived address: {}", s.address()), + elapsed_ms(start), + )); + s + } + Err(e) => { + results.push(CheckResult::fail( + "gonka.signer", + format!("key parse failed: {e}"), + elapsed_ms(start), + )); + let report = DoctorReport { + results, + elapsed_ms: elapsed_ms(total_start), + }; + return finish(&report, json); + } + }; + + // If vault stored an explicit address, verify it matches the derived one. + if let Some(ref vault_addr) = vault_addr_opt { + let derived = signer.address(); + if vault_addr != derived { + let start = Instant::now(); + results.push(CheckResult::fail( + "gonka.signer", + format!("vault address does not match derived address: vault={vault_addr}, derived={derived}"), + elapsed_ms(start), + )); + let report = DoctorReport { + results, + elapsed_ms: elapsed_ms(total_start), + }; + return finish(&report, json); + } + } + + // 5. Build shared HTTP client (once, not per probe) + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + { + Ok(c) => Arc::new(c), + Err(e) => { + tracing::warn!(error = %e, "gonka: client build failed"); + results.push(CheckResult::fail( + "gonka.client", + "HTTP client build failed", + 0, + )); + let report = DoctorReport { + results, + elapsed_ms: elapsed_ms(total_start), + }; + return finish(&report, json); + } + }; + + // 6. Per-node probes — concurrent via JoinSet + let signer = Arc::new(signer); + probe_all_nodes(&gonka_providers, signer, client, timeout_secs, &mut results).await; + + let report = DoctorReport { + results, + elapsed_ms: elapsed_ms(total_start), + }; + finish(&report, json) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "gonka")] + #[test] + fn gonka_doctor_cli_parses() { + use crate::cli::{Cli, Command, GonkaCommand}; + use clap::Parser; + + let cli = Cli::try_parse_from(["zeph", "gonka", "doctor"]).unwrap(); + assert!(matches!( + cli.command, + Some(Command::Gonka { + command: GonkaCommand::Doctor { + json: false, + timeout_secs: 10 + } + }) + )); + } + + #[cfg(feature = "gonka")] + #[test] + fn gonka_doctor_cli_parses_json_flag() { + use crate::cli::{Cli, Command, GonkaCommand}; + use clap::Parser; + + let cli = Cli::try_parse_from(["zeph", "gonka", "doctor", "--json"]).unwrap(); + assert!(matches!( + cli.command, + Some(Command::Gonka { + command: GonkaCommand::Doctor { json: true, .. } + }) + )); + } + + #[test] + fn gonka_detect_clock_skew_none_within_threshold() { + let now = chrono::Utc::now(); + let date_str = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); + assert!(detect_clock_skew(&date_str).is_none()); + } + + #[test] + fn gonka_detect_clock_skew_detects_large_delta() { + let past = chrono::Utc::now() - chrono::Duration::seconds(120); + let date_str = past.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); + let result = detect_clock_skew(&date_str); + assert!(result.is_some(), "expected skew detection for 120s delta"); + let msg = result.unwrap(); + assert!(msg.contains("clock skew"), "unexpected: {msg}"); + } + + #[test] + fn gonka_detect_clock_skew_returns_none_for_invalid_date() { + assert!(detect_clock_skew("not a date").is_none()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fe8981d55..25b77f551 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,8 @@ pub(crate) mod bench; pub(crate) mod classifiers; pub(crate) mod db; pub(crate) mod doctor; +#[cfg(feature = "gonka")] +pub(crate) mod gonka; pub(crate) mod ingest; pub(crate) mod memory; pub(crate) mod migrate; diff --git a/src/runner.rs b/src/runner.rs index 43dfb2433..af6d85c30 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -558,6 +558,15 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { .await?; std::process::exit(exit_code); } + #[cfg(feature = "gonka")] + Some(Command::Gonka { + command: crate::cli::GonkaCommand::Doctor { json, timeout_secs }, + }) => { + let config_path = resolve_config_path(cli.config.as_deref()); + let exit_code = + crate::commands::gonka::run_gonka_doctor(&config_path, json, timeout_secs).await?; + std::process::exit(exit_code); + } Some(Command::Notify { command: crate::cli::NotifyCommand::Test, }) => {