From 17e52b5a2f60fbc57f5403b23d070422264b5a36 Mon Sep 17 00:00:00 2001 From: Michel Paulissen <862400+MichelPaulissen@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:29:17 +0200 Subject: [PATCH] Public dry run dryrun-33643196cbdf --- DISASMER_PUBLIC_TREE.json | 4 +- crates/disasmer-cli/src/main.rs | 164 +++++++++++++++++++++++++++- scripts/cli-first-contract-smoke.js | 3 + scripts/cli-output-mode-smoke.js | 5 + 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index bba39ed..78a8aff 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "f6bbcf3a2f6fd20b724a51a04861ea480d4c5607", - "release_name": "dryrun-f6bbcf3a2f6f", + "source_commit": "33643196cbdfe5eeacd69bc39b8cdf86cb924d2d", + "release_name": "dryrun-33643196cbdf", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 1189368..c454221 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1,5 +1,5 @@ use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; +use std::net::{TcpListener, TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -19,6 +19,7 @@ const DEFAULT_OIDC_ISSUER_URL: &str = "https://auth.michelpaulissen.com"; const BROWSER_CALLBACK_ADDR: &str = "127.0.0.1:45173"; const BROWSER_CALLBACK_PATH: &str = "/callback"; const DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS: u64 = 300; +const DOCTOR_COORDINATOR_TIMEOUT: Duration = Duration::from_millis(500); #[derive(Clone, Debug, Parser)] #[command(name = "disasmer", version, arg_required_else_help = true)] @@ -639,10 +640,17 @@ struct ProjectConfig { fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { let config = read_project_config(&cwd)?; + let coordinator = args.scope.coordinator.or_else(|| { + config + .as_ref() + .and_then(|config| config.coordinator.clone()) + }); + let coordinator_reachability = coordinator_reachability(coordinator.as_deref()); Ok(json!({ "command": "doctor", "cwd": cwd, - "coordinator": args.scope.coordinator.or_else(|| config.as_ref().and_then(|config| config.coordinator.clone())), + "coordinator": coordinator, + "coordinator_reachability": coordinator_reachability, "auth": auth_state_value(), "project": config, "dependencies": { @@ -663,6 +671,73 @@ fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { })) } +fn coordinator_reachability(coordinator: Option<&str>) -> Value { + let Some(coordinator) = coordinator else { + return json!({ + "checked": false, + "status": "not_configured", + "next_action": "run disasmer login --browser or pass --coordinator" + }); + }; + + match ping_coordinator(coordinator, DOCTOR_COORDINATOR_TIMEOUT) { + Ok(response) => json!({ + "checked": true, + "status": "reachable", + "coordinator": coordinator, + "response": response + }), + Err(err) => json!({ + "checked": true, + "status": "unreachable", + "coordinator": coordinator, + "error": err.to_string(), + "next_action": "check the coordinator URL, network, and service status" + }), + } +} + +fn ping_coordinator(coordinator: &str, timeout: Duration) -> Result { + let transport_addr = json_line_transport_addr(coordinator); + let socket_addrs = transport_addr + .to_socket_addrs() + .with_context(|| format!("failed to resolve {coordinator} via {transport_addr}"))?; + let mut saw_addr = false; + let mut last_error = None; + + for socket_addr in socket_addrs { + saw_addr = true; + match TcpStream::connect_timeout(&socket_addr, timeout) { + Ok(stream) => { + stream + .set_read_timeout(Some(timeout)) + .with_context(|| format!("failed to set read timeout for {socket_addr}"))?; + stream + .set_write_timeout(Some(timeout)) + .with_context(|| format!("failed to set write timeout for {socket_addr}"))?; + let mut session = JsonLineSession::from_stream(stream)?; + return session.request(json!({ "type": "ping" })).with_context(|| { + format!("coordinator ping failed for {coordinator} via {socket_addr}") + }); + } + Err(err) => last_error = Some(err), + } + } + + if !saw_addr { + anyhow::bail!( + "failed to resolve any socket address for {coordinator} via {transport_addr}" + ); + } + + anyhow::bail!( + "failed to connect to {coordinator} via {transport_addr}: {}", + last_error + .map(|err| err.to_string()) + .unwrap_or_else(|| "no socket addresses attempted".to_owned()) + ); +} + fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { let config = read_project_config(&cwd)?; Ok(json!({ @@ -1363,6 +1438,20 @@ fn human_report(value: &Value) -> String { )); } } + if let Some(reachability) = value.get("coordinator_reachability") { + if let Some(status) = reachability.get("status").and_then(Value::as_str) { + lines.push(format!("coordinator reachability: {status}")); + } + if let Some(error) = reachability.get("error").and_then(Value::as_str) { + lines.push(format!("coordinator error: {error}")); + } + if let Some(response_type) = reachability + .pointer("/response/type") + .and_then(Value::as_str) + { + lines.push(format!("coordinator ping: {response_type}")); + } + } if let Some(response) = value .get("response") .or_else(|| value.get("coordinator_response")) @@ -2724,6 +2813,10 @@ impl JsonLineSession { let transport_addr = json_line_transport_addr(addr); let writer = TcpStream::connect(&transport_addr) .with_context(|| format!("failed to connect to {addr} via {transport_addr}"))?; + Self::from_stream(writer) + } + + fn from_stream(writer: TcpStream) -> Result { let reader = BufReader::new(writer.try_clone()?); Ok(Self { writer, @@ -3198,6 +3291,73 @@ mod tests { assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999"); } + #[test] + fn doctor_reports_unchecked_coordinator_reachability_without_config() { + let temp = tempfile::tempdir().unwrap(); + let report = doctor_report( + DoctorArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + + assert_eq!(report["command"], "doctor"); + assert!(report["coordinator"].is_null()); + assert_eq!(report["coordinator_reachability"]["checked"], false); + assert_eq!( + report["coordinator_reachability"]["status"], + "not_configured" + ); + } + + #[test] + fn doctor_pings_configured_coordinator() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains("\"type\":\"ping\"")); + stream + .write_all(b"{\"type\":\"pong\",\"epoch\":42}\n") + .unwrap(); + }); + + let temp = tempfile::tempdir().unwrap(); + let report = doctor_report( + DoctorArgs { + scope: CliScopeArgs { + coordinator: Some(addr.clone()), + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(report["coordinator"], addr); + assert_eq!(report["coordinator_reachability"]["checked"], true); + assert_eq!(report["coordinator_reachability"]["status"], "reachable"); + assert_eq!( + report["coordinator_reachability"]["response"]["type"], + "pong" + ); + assert_eq!(report["coordinator_reachability"]["response"]["epoch"], 42); + } + #[test] fn cli_first_mvp_command_surface_parses() { for args in [ diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 7f7f382..2911040 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -104,6 +104,8 @@ for (const [name, pattern] of [ ["CLI version coverage", /fn top_level_version_is_available\(\)/], ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], + ["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/], + ["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/], ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], ["build no full repo upload coverage", /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)/], ["safe coordinator-required plans", /fn node_enroll_and_process_commands_have_safe_plan_without_coordinator\(\)/], @@ -127,6 +129,7 @@ for (const [name, pattern] of [ ["human default assertion", /default output should be human-readable text, not JSON/], ["login JSON mode", /\["login", "--coordinator", "https:\/\/coord\.example\.test", "--json"\]/], ["doctor human mode", /\["doctor"\]/], + ["doctor reachability JSON mode", /doctorJson\.coordinator_reachability\.status/], ["bundle inspect JSON mode", /\["bundle", "inspect", "--project", project, "--json"\]/], ["auth expiry posture", /token_expiry_posture[\s\S]*expires_at/], ]) { diff --git a/scripts/cli-output-mode-smoke.js b/scripts/cli-output-mode-smoke.js index 711a07f..010c56c 100644 --- a/scripts/cli-output-mode-smoke.js +++ b/scripts/cli-output-mode-smoke.js @@ -50,11 +50,16 @@ assert(loginJson.human_flow.Device); const doctorHuman = disasmer(["doctor"]); assertHuman("doctor", doctorHuman, [ /Disasmer doctor/, + /coordinator reachability: not_configured/, /dependencies:/, /auth:/, /node capabilities:/, ]); +const doctorJson = json(["doctor", "--json"]); +assert.strictEqual(doctorJson.coordinator_reachability.checked, false); +assert.strictEqual(doctorJson.coordinator_reachability.status, "not_configured"); + const authJson = json(["auth", "status", "--json"], { DISASMER_TOKEN: "token", DISASMER_TOKEN_EXPIRES_AT: "2026-07-04T00:00:00Z",