diff --git a/Cargo.lock b/Cargo.lock index c0ac1d1df..6ce320900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2882,6 +2882,7 @@ dependencies = [ "flate2", "futures", "git2", + "glob", "globset", "handlebars", "hex", diff --git a/apps/framework-cli-e2e/test/cli-doctor.test.ts b/apps/framework-cli-e2e/test/cli-doctor.test.ts new file mode 100644 index 000000000..7ce641d66 --- /dev/null +++ b/apps/framework-cli-e2e/test/cli-doctor.test.ts @@ -0,0 +1,299 @@ +/// +/// +/// +/** + * E2E tests for moose doctor command (ENG-1252) + * + * Tests the doctor command functionality: + * 1. Execute diagnostics with default options + * 2. Filter by severity level + * 3. Output as JSON + * 4. Use verbosity flags + * 5. Filter by component pattern + */ + +import { spawn, ChildProcess } from "child_process"; +import { expect } from "chai"; +import * as path from "path"; +import { promisify } from "util"; + +import { TIMEOUTS } from "./constants"; +import { + waitForServerStart, + createTempTestDirectory, + cleanupTestSuite, + setupTypeScriptProject, +} from "./utils"; + +const execAsync = promisify(require("child_process").exec); + +const CLI_PATH = path.resolve(__dirname, "../../../target/debug/moose-cli"); +const MOOSE_TS_LIB_PATH = path.resolve( + __dirname, + "../../../packages/ts-moose-lib", +); + +describe("moose doctor command", () => { + let devProcess: ChildProcess; + let testProjectDir: string; + + before(async function () { + this.timeout(TIMEOUTS.TEST_SETUP_MS); + + console.log("\n=== Starting Doctor Command Test ==="); + + // Create temp test directory + testProjectDir = createTempTestDirectory("doctor-cmd-test"); + console.log("Test project dir:", testProjectDir); + + // Setup TypeScript project + await setupTypeScriptProject( + testProjectDir, + "typescript-empty", + CLI_PATH, + MOOSE_TS_LIB_PATH, + "test-doctor-cmd", + "npm", + ); + + // Start moose dev + console.log("\nStarting moose dev..."); + devProcess = spawn(CLI_PATH, ["dev"], { + stdio: "pipe", + cwd: testProjectDir, + }); + + await waitForServerStart( + devProcess, + TIMEOUTS.SERVER_STARTUP_MS, + "development server started", + "http://localhost:4000", + ); + + console.log("✓ Infrastructure ready"); + }); + + after(async function () { + this.timeout(TIMEOUTS.CLEANUP_MS); + console.log("\n=== Cleaning up Doctor Command Test ==="); + + await cleanupTestSuite(devProcess, testProjectDir, "doctor-cmd-test", { + logPrefix: "Doctor Command Test", + }); + }); + + it("should execute doctor with default options", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing doctor with default options ---"); + + const { stdout } = await execAsync(`"${CLI_PATH}" doctor`, { + cwd: testProjectDir, + }); + + console.log("Doctor output:", stdout); + + // Should show summary even with no issues + expect(stdout).to.include("Summary:"); + expect(stdout).to.match(/\d+ errors?, \d+ warnings?, \d+ info messages?/); + + console.log("✓ Doctor command runs with defaults"); + }); + + it("should execute doctor with JSON output", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing doctor with JSON output ---"); + + const { stdout } = await execAsync(`"${CLI_PATH}" doctor --json`, { + cwd: testProjectDir, + }); + + console.log("Doctor JSON output:", stdout.substring(0, 200)); + + // Parse JSON to ensure it's valid + const output = JSON.parse(stdout); + + expect(output).to.have.property("issues"); + expect(output).to.have.property("summary"); + expect(output.summary).to.have.property("total_issues"); + expect(output.summary).to.have.property("by_severity"); + expect(output.summary).to.have.property("by_component"); + + console.log("✓ JSON output is valid"); + }); + + it("should filter by severity level", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing severity filtering ---"); + + // Test with info severity (should include everything) + const { stdout: infoOutput } = await execAsync( + `"${CLI_PATH}" doctor --severity info`, + { + cwd: testProjectDir, + }, + ); + + expect(infoOutput).to.include("Summary:"); + + // Test with error severity (default) + const { stdout: errorOutput } = await execAsync( + `"${CLI_PATH}" doctor --severity error`, + { + cwd: testProjectDir, + }, + ); + + expect(errorOutput).to.include("Summary:"); + + console.log("✓ Severity filtering works"); + }); + + it("should respect verbosity flags", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing verbosity flags ---"); + + // Test with -v + const { stdout: verboseOutput } = await execAsync( + `"${CLI_PATH}" doctor -v`, + { + cwd: testProjectDir, + }, + ); + + expect(verboseOutput).to.include("Summary:"); + + // Test with -vv + const { stdout: veryVerboseOutput } = await execAsync( + `"${CLI_PATH}" doctor -vv`, + { + cwd: testProjectDir, + }, + ); + + expect(veryVerboseOutput).to.include("Summary:"); + + // Test with -vvv + const { stdout: maxVerboseOutput } = await execAsync( + `"${CLI_PATH}" doctor -vvv`, + { + cwd: testProjectDir, + }, + ); + + expect(maxVerboseOutput).to.include("Summary:"); + + console.log("✓ Verbosity flags work"); + }); + + it("should filter by component pattern", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing component filtering ---"); + + // Use a glob pattern that won't match anything + const { stdout } = await execAsync( + `"${CLI_PATH}" doctor --component "nonexistent_*"`, + { + cwd: testProjectDir, + }, + ); + + expect(stdout).to.include("Summary:"); + // With no matching components, should show 0 issues + expect(stdout).to.match(/0 errors?, 0 warnings?, 0 info messages?/); + + console.log("✓ Component filtering works"); + }); + + it("should respect since parameter", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing since parameter ---"); + + // Test with different time windows + const { stdout: hours1 } = await execAsync( + `"${CLI_PATH}" doctor --since "1 hour"`, + { + cwd: testProjectDir, + }, + ); + + expect(hours1).to.include("Summary:"); + + const { stdout: days1 } = await execAsync( + `"${CLI_PATH}" doctor --since "1 day"`, + { + cwd: testProjectDir, + }, + ); + + expect(days1).to.include("Summary:"); + + const { stdout: minutes30 } = await execAsync( + `"${CLI_PATH}" doctor --since "30m"`, + { + cwd: testProjectDir, + }, + ); + + expect(minutes30).to.include("Summary:"); + + console.log("✓ Since parameter works"); + }); + + it("should combine multiple options", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing combined options ---"); + + const { stdout } = await execAsync( + `"${CLI_PATH}" doctor --severity warning --json -v`, + { + cwd: testProjectDir, + }, + ); + + // Should be valid JSON + const output = JSON.parse(stdout); + expect(output).to.have.property("issues"); + expect(output).to.have.property("summary"); + + console.log("✓ Combined options work"); + }); + + it("should handle invalid severity gracefully", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing invalid severity handling ---"); + + try { + await execAsync(`"${CLI_PATH}" doctor --severity invalid`, { + cwd: testProjectDir, + }); + expect.fail("Should have thrown an error"); + } catch (error: any) { + expect(error.message).to.match(/Failed to parse severity|must be one of/); + console.log("✓ Invalid severity handled gracefully"); + } + }); + + it("should handle invalid duration gracefully", async function () { + this.timeout(TIMEOUTS.MIGRATION_MS); + + console.log("\n--- Testing invalid duration handling ---"); + + try { + await execAsync(`"${CLI_PATH}" doctor --since "invalid"`, { + cwd: testProjectDir, + }); + expect.fail("Should have thrown an error"); + } catch (error: any) { + expect(error.message).to.match(/Failed to parse time duration/); + console.log("✓ Invalid duration handled gracefully"); + } + }); +}); diff --git a/apps/framework-cli/Cargo.toml b/apps/framework-cli/Cargo.toml index 99c977e26..8e323ca6e 100644 --- a/apps/framework-cli/Cargo.toml +++ b/apps/framework-cli/Cargo.toml @@ -67,6 +67,7 @@ pbkdf2 = { version = "0.12", features = ["simple"] } sha2 = "0.10.8" hex = "0.4.2" constant_time_eq = "0.3.0" +glob = "0.3" tokio-stream = "0.1.16" redis = { version = "0.29.1", features = [ diff --git a/apps/framework-cli/src/cli.rs b/apps/framework-cli/src/cli.rs index 4d3e306ca..1d27ad303 100644 --- a/apps/framework-cli/src/cli.rs +++ b/apps/framework-cli/src/cli.rs @@ -1386,6 +1386,44 @@ pub async fn top_command_handler( wait_for_usage_capture(capture_handle).await; + result + } + Commands::Doctor { + severity, + component, + since, + json, + verbose, + clickhouse_url, + redis_url, + } => { + info!("Running doctor command"); + + let project = load_project(commands)?; + let project_arc = Arc::new(project); + + let capture_handle = crate::utilities::capture::capture_usage( + ActivityType::DoctorCommand, + Some(project_arc.name()), + &settings, + machine_id.clone(), + HashMap::new(), + ); + + let result = routines::doctor::diagnose_infrastructure( + project_arc, + severity.clone(), + component.clone(), + since.clone(), + *json, + *verbose, + clickhouse_url.clone(), + redis_url.clone(), + ) + .await; + + wait_for_usage_capture(capture_handle).await; + result } } diff --git a/apps/framework-cli/src/cli/commands.rs b/apps/framework-cli/src/cli/commands.rs index 5a76bce88..3e95b7cb3 100644 --- a/apps/framework-cli/src/cli/commands.rs +++ b/apps/framework-cli/src/cli/commands.rs @@ -206,6 +206,36 @@ pub enum Commands { #[arg(short, long, default_value = "10000")] limit: u64, }, + /// Diagnose infrastructure health and surface issues + Doctor { + /// Minimum severity level to report (error, warning, info) + #[arg(long, default_value = "error")] + severity: String, + + /// Filter by component name pattern (glob pattern, e.g., "users_*") + #[arg(long)] + component: Option, + + /// Time window to check (e.g., "6 hours", "1 day", "30m") + #[arg(long, default_value = "6 hours")] + since: String, + + /// Output results in JSON format + #[arg(long, default_value = "false")] + json: bool, + + /// Increase verbosity level (can be used multiple times: -v, -vv, -vvv) + #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)] + verbose: u8, + + /// ClickHouse connection URL (optional, falls back to project config) + #[arg(long)] + clickhouse_url: Option, + + /// Redis connection URL (optional, falls back to project config) + #[arg(long)] + redis_url: Option, + }, } #[derive(Debug, Args)] diff --git a/apps/framework-cli/src/cli/routines/doctor.rs b/apps/framework-cli/src/cli/routines/doctor.rs new file mode 100644 index 000000000..05fd97eb9 --- /dev/null +++ b/apps/framework-cli/src/cli/routines/doctor.rs @@ -0,0 +1,328 @@ +//! # Doctor Routine +//! +//! Diagnostic routine for infrastructure health checking. This command surfaces +//! errors and issues in ClickHouse, Redis, and other infrastructure components. +//! +//! ## Features +//! - Proactive diagnostics using infrastructure map +//! - Configurable severity filtering (error, warning, info) +//! - Component filtering with glob patterns +//! - Time-based filtering (e.g., "6 hours", "1 day") +//! - Multiple output formats (human-readable, JSON) +//! - Verbosity control for detailed metadata + +use std::collections::HashMap; +use std::sync::Arc; + +use log::{debug, info}; + +use crate::cli::display::Message; +use crate::cli::routines::{setup_redis_client, RoutineFailure, RoutineSuccess}; +use crate::framework::core::infrastructure_map::InfrastructureMap; +use crate::infrastructure::olap::clickhouse::config::parse_clickhouse_connection_string; +use crate::infrastructure::olap::clickhouse::diagnostics::{ + Component, DiagnosticOptions, DiagnosticOutput, DiagnosticRequest, Severity, +}; +use crate::project::Project; + +/// Error types for doctor routine operations +#[derive(Debug, thiserror::Error)] +#[allow(dead_code)] +pub enum DoctorError { + #[error("Failed to parse severity '{0}': must be one of: error, warning, info")] + InvalidSeverity(String), + + #[error("Failed to parse time duration '{0}': {1}")] + InvalidDuration(String, String), + + #[error("Failed to load infrastructure map: {0}")] + InfraMapLoad(String), + + #[error("Failed to parse ClickHouse connection string: {0}")] + ClickHouseConfig(String), + + #[error("Failed to setup Redis client: {0}")] + RedisClient(String), + + #[error("Failed to run diagnostics: {0}")] + DiagnosticFailed(String), + + #[error("Failed to compile glob pattern '{pattern}': {error}")] + InvalidGlob { pattern: String, error: String }, +} + +/// Parse severity string into Severity enum +fn parse_severity(severity_str: &str) -> Result { + match severity_str.to_lowercase().as_str() { + "error" => Ok(Severity::Error), + "warning" => Ok(Severity::Warning), + "info" => Ok(Severity::Info), + _ => Err(DoctorError::InvalidSeverity(severity_str.to_string())), + } +} + +/// Parse humantime duration string into ClickHouse interval format +/// Examples: "6 hours" -> "-6h", "1 day" -> "-1d", "30m" -> "-30m" +fn parse_since(since_str: &str) -> Result { + // Try to parse with humantime crate + let duration = humantime::parse_duration(since_str) + .map_err(|e| DoctorError::InvalidDuration(since_str.to_string(), e.to_string()))?; + + // Convert to ClickHouse interval format (negative for relative to now) + let total_seconds = duration.as_secs(); + + // Choose appropriate unit for readability + if total_seconds % 3600 == 0 { + let hours = total_seconds / 3600; + Ok(format!("-{}h", hours)) + } else if total_seconds % 60 == 0 { + let minutes = total_seconds / 60; + Ok(format!("-{}m", minutes)) + } else { + Ok(format!("-{}s", total_seconds)) + } +} + +/// Main doctor routine entry point +#[allow(clippy::too_many_arguments)] +pub async fn diagnose_infrastructure( + project: Arc, + severity_str: String, + component_pattern: Option, + since_str: String, + json_output: bool, + verbosity: u8, + clickhouse_url: Option, + redis_url: Option, +) -> Result { + info!("Starting infrastructure diagnostics"); + + // Parse severity + let severity = parse_severity(&severity_str).map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: e.to_string(), + }) + })?; + + // Parse since duration + let since = parse_since(&since_str).map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: e.to_string(), + }) + })?; + + debug!("Parsed severity: {:?}, since: {}", severity, since); + + // Setup Redis client + let redis_client = if let Some(ref url) = redis_url { + // TODO: Create redis client from custom URL + // For now, fall back to project config + debug!("Custom Redis URL provided: {}", url); + setup_redis_client(project.clone()).await.map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: format!("Failed to setup redis client: {:?}", e), + }) + })? + } else { + setup_redis_client(project.clone()).await.map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: format!("Failed to setup redis client: {:?}", e), + }) + })? + }; + + // Setup ClickHouse config + let clickhouse_config = if let Some(ref url) = clickhouse_url { + debug!("Using custom ClickHouse URL"); + parse_clickhouse_connection_string(url).map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: format!("Failed to parse ClickHouse connection string: {:?}", e), + }) + })? + } else { + debug!("Using project ClickHouse config"); + project.clickhouse_config.clone() + }; + + // Load infrastructure map + debug!("Loading infrastructure map from Redis"); + let infra_map = InfrastructureMap::load_from_redis(&redis_client) + .await + .map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: format!("Failed to load infrastructure map: {:?}", e), + }) + })? + .ok_or_else(|| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: "No infrastructure map found. The dev server may not be running." + .to_string(), + }) + })?; + + // Filter tables based on component pattern + let tables_to_check: Vec<_> = if let Some(ref pattern) = component_pattern { + debug!("Filtering tables with pattern: {}", pattern); + infra_map + .tables + .iter() + .filter(|(_map_key, table)| { + // Use glob matching + let glob_pattern = + glob::Pattern::new(pattern).map_err(|e| DoctorError::InvalidGlob { + pattern: pattern.clone(), + error: e.to_string(), + }); + + match glob_pattern { + Ok(glob) => glob.matches(&table.name), + Err(_) => false, + } + }) + .collect() + } else { + infra_map.tables.iter().collect() + }; + + info!("Checking {} tables for issues", tables_to_check.len()); + + // Build diagnostic request with components from infrastructure map + let components: Vec<_> = tables_to_check + .iter() + .map(|(_map_key, table)| { + let mut metadata = HashMap::new(); + metadata.insert("database".to_string(), clickhouse_config.db_name.clone()); + + let component = Component { + component_type: "table".to_string(), + name: table.name.clone(), + metadata, + }; + + (component, table.engine.clone()) + }) + .collect(); + + let request = DiagnosticRequest { + components, + options: DiagnosticOptions { + diagnostic_names: Vec::new(), // Run all diagnostics + min_severity: severity, + since: Some(since), + }, + }; + + // Run diagnostics + let output = crate::infrastructure::olap::clickhouse::diagnostics::run_diagnostics( + request, + &clickhouse_config, + ) + .await + .map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: format!("Diagnostics failed: {}", e), + }) + })?; + + info!( + "Infrastructure diagnostics complete. Found {} issues.", + output.issues.len() + ); + + // Format and display output + if json_output { + format_json_output(&output) + } else { + format_human_readable_output(&output, verbosity, &infra_map) + } +} + +/// Format output as JSON +fn format_json_output(output: &DiagnosticOutput) -> Result { + let json_str = serde_json::to_string_pretty(output).map_err(|e| { + RoutineFailure::error(Message { + action: "Doctor".to_string(), + details: format!("Failed to format output as JSON: {}", e), + }) + })?; + + Ok(RoutineSuccess::highlight(Message { + action: "Doctor".to_string(), + details: json_str, + })) +} + +/// Format output as human-readable text +fn format_human_readable_output( + output: &DiagnosticOutput, + verbosity: u8, + _infra_map: &InfrastructureMap, +) -> Result { + let mut result = String::new(); + + // Show each issue + for issue in &output.issues { + result.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + result.push_str(&format!("{:?}: {}\n", issue.severity, issue.source)); + result.push_str(&format!("Component: {}\n", issue.component.name)); + result.push_str(&format!("Message: {}\n", issue.message)); + + if !issue.suggested_action.is_empty() { + result.push_str(&format!("Suggested Action: {}\n", issue.suggested_action)); + } + + // Show details based on verbosity + if verbosity >= 1 { + // -v: Add component metadata + if !issue.component.metadata.is_empty() { + result.push_str("Metadata:\n"); + for (key, value) in &issue.component.metadata { + result.push_str(&format!(" {}: {}\n", key, value)); + } + } + } + + if verbosity >= 3 { + // -vvv: Add all details + if !issue.details.is_empty() { + result.push_str("Details:\n"); + for (key, value) in &issue.details { + result.push_str(&format!(" {}: {}\n", key, value)); + } + } + } + + result.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"); + } + + // Show summary + let error_count = output.summary.by_severity.get("Error").unwrap_or(&0); + let warning_count = output.summary.by_severity.get("Warning").unwrap_or(&0); + let info_count = output.summary.by_severity.get("Info").unwrap_or(&0); + + result.push_str(&format!( + "Summary: {} errors, {} warnings, {} info messages\n", + error_count, warning_count, info_count + )); + + // Show breakdown by component (verbosity >= 2) + if verbosity >= 2 && !output.summary.by_component.is_empty() { + result.push_str("\nIssues by component:\n"); + for (component, count) in &output.summary.by_component { + result.push_str(&format!(" - {}: {} issue(s)\n", component, count)); + } + } + + Ok(RoutineSuccess::highlight(Message { + action: "Doctor".to_string(), + details: result, + })) +} diff --git a/apps/framework-cli/src/cli/routines/mod.rs b/apps/framework-cli/src/cli/routines/mod.rs index e508cf621..41704df8e 100644 --- a/apps/framework-cli/src/cli/routines/mod.rs +++ b/apps/framework-cli/src/cli/routines/mod.rs @@ -161,6 +161,7 @@ pub mod clean; pub mod code_generation; pub mod dev; pub mod docker_packager; +pub mod doctor; pub mod kafka_pull; pub mod logs; pub mod ls; diff --git a/apps/framework-cli/src/utilities/capture.rs b/apps/framework-cli/src/utilities/capture.rs index 106f3a16d..589b06846 100644 --- a/apps/framework-cli/src/utilities/capture.rs +++ b/apps/framework-cli/src/utilities/capture.rs @@ -67,6 +67,8 @@ pub enum ActivityType { PeekCommand, #[serde(rename = "queryCommand")] QueryCommand, + #[serde(rename = "doctorCommand")] + DoctorCommand, #[serde(rename = "workflowCommand")] WorkflowCommand, #[serde(rename = "workflowInitCommand")] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20d90858f..fde755e67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15357,7 +15357,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -15408,7 +15408,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3