mecha10-node-runner 0.8.0

Node lifecycle manager for Mecha10 - handles pre-flight checks and node execution
//! Pre-flight system checks for node runner
//!
//! This module performs comprehensive system checks before node execution:
//! - Redis connectivity
//! - Disk space availability
//! - Port availability
//! - File system permissions
//! - GPU availability (optional)
//!
//! All checks provide actionable error messages with remediation steps.
//!
//! # Node-specific config content checks (LAB-1773)
//!
//! Everything in this module is *machine/environment* level - it has no
//! knowledge of any individual node's config content (e.g. whether a camera's
//! `device_path` exists, or a motor's GPIO pins collide). `node-runner` spawns
//! each node as a separate subprocess resolved by name (see `main()`'s
//! `resolve_node_execution` -> [`crate::supervisor::Supervisor::supervise`]),
//! so it never has a typed handle on the node's own config struct to check
//! that content against.
//!
//! That complementary "prerun" layer instead lives in the node's own process,
//! as the optional `NodeImpl::preflight()` hook (`mecha10_core::node::NodeImpl`,
//! called from `Node::start()` in `packages/core/src/node.rs`, right after
//! `init()` and before `run()`). See `packages/nodes/{camera,motor,
//! object-detector,image-classifier}` for example implementations. It follows
//! the same actionable-error-message convention as the checks in this file
//! (`❌` header + numbered remediation steps).

use anyhow::{Context, Result};
use std::path::Path;
use tracing::{debug, info, warn};

#[derive(Debug, Clone)]
pub struct PreflightConfig {
    pub check_ports: Vec<u16>,
    pub min_disk_space_gb: u64,
    pub require_gpu: bool,
    pub redis_url: String,
}

impl Default for PreflightConfig {
    fn default() -> Self {
        Self {
            check_ports: vec![],
            min_disk_space_gb: 1,
            require_gpu: false,
            redis_url: "redis://localhost:6379".to_string(),
        }
    }
}

/// Run all pre-flight checks
///
/// Returns Ok if all checks pass, or an error with actionable remediation steps.
pub async fn run_preflight_checks(config: &PreflightConfig) -> Result<()> {
    info!("🔍 Running pre-flight checks...");

    check_redis_connectivity(&config.redis_url).await?;
    check_disk_space(config.min_disk_space_gb)?;
    check_ports(&config.check_ports)?;
    check_permissions()?;

    if config.require_gpu {
        check_gpu_availability()?;
    }

    info!("✅ All pre-flight checks passed");
    Ok(())
}

/// Check Redis connectivity by pinging the server
async fn check_redis_connectivity(redis_url: &str) -> Result<()> {
    let client = redis::Client::open(redis_url).with_context(|| {
        format!(
            "❌ Pre-flight check failed: Cannot connect to Redis at {}\n\n\
             Possible solutions:\n\
             1. Start Redis: docker compose up -d redis\n\
             2. Check REDIS_URL environment variable\n\
             3. Skip this check with: --skip-preflight (not recommended)\n",
            redis_url
        )
    })?;

    let mut conn = client.get_multiplexed_async_connection().await.with_context(|| {
        format!(
            "❌ Pre-flight check failed: Redis not reachable at {}\n\n\
                 Possible solutions:\n\
                 1. Verify Redis is running: docker compose ps\n\
                 2. Check network connectivity\n\
                 3. Skip this check with: --skip-preflight (not recommended)\n",
            redis_url
        )
    })?;

    redis::cmd("PING")
        .query_async::<String>(&mut conn)
        .await
        .with_context(|| {
            format!(
                "❌ Pre-flight check failed: Redis ping failed at {}\n\n\
                 Possible solutions:\n\
                 1. Check Redis server health: docker compose logs redis\n\
                 2. Restart Redis: docker compose restart redis\n\
                 3. Skip this check with: --skip-preflight (not recommended)\n",
                redis_url
            )
        })?;

    debug!("✅ Redis connectivity: OK");
    Ok(())
}

/// Check available disk space
fn check_disk_space(min_gb: u64) -> Result<()> {
    #[cfg(target_os = "linux")]
    {
        check_disk_space_unix(min_gb)
    }

    #[cfg(target_os = "macos")]
    {
        check_disk_space_unix(min_gb)
    }

    #[cfg(target_os = "windows")]
    {
        check_disk_space_windows(min_gb)
    }

    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        warn!("⚠️  Disk space check not supported on this platform");
        Ok(())
    }
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn check_disk_space_unix(min_gb: u64) -> Result<()> {
    use std::ffi::CString;
    use std::mem::MaybeUninit;

    let path = CString::new(".").unwrap();
    let mut stat: MaybeUninit<libc::statvfs> = MaybeUninit::uninit();

    let result = unsafe { libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) };

    if result != 0 {
        warn!("⚠️  Could not check disk space (continuing anyway)");
        return Ok(());
    }

    let stat = unsafe { stat.assume_init() };
    #[allow(clippy::unnecessary_cast)]
    let available_bytes = stat.f_bavail as u64 * stat.f_frsize as u64;
    let available_gb = available_bytes / (1024 * 1024 * 1024);
    let min_bytes = min_gb * 1024 * 1024 * 1024;

    if available_bytes < min_bytes {
        anyhow::bail!(
            "❌ Pre-flight check failed: Insufficient disk space\n\n\
             Available: {:.2} GB\n\
             Required:  {} GB\n\n\
             Possible solutions:\n\
             1. Free up disk space (delete unused files, clear caches)\n\
             2. Use a different working directory with more space\n\
             3. Skip this check with: --skip-preflight (not recommended)\n\
             4. Adjust minimum in node config: \"preflight\": {{ \"min_disk_space_gb\": <lower-value> }}\n",
            available_gb as f64,
            min_gb
        );
    }

    debug!(
        "✅ Disk space: {:.2} GB available (>= {} GB required)",
        available_gb as f64, min_gb
    );
    Ok(())
}

#[cfg(target_os = "windows")]
fn check_disk_space_windows(min_gb: u64) -> Result<()> {
    use std::ffi::OsStr;
    use std::os::windows::ffi::OsStrExt;

    let path: Vec<u16> = OsStr::new(".").encode_wide().chain(Some(0)).collect();

    let mut free_bytes: u64 = 0;
    let mut total_bytes: u64 = 0;
    let mut total_free_bytes: u64 = 0;

    let result = unsafe {
        winapi::um::fileapi::GetDiskFreeSpaceExW(
            path.as_ptr(),
            &mut free_bytes as *mut u64 as *mut _,
            &mut total_bytes as *mut u64 as *mut _,
            &mut total_free_bytes as *mut u64 as *mut _,
        )
    };

    if result == 0 {
        warn!("⚠️  Could not check disk space (continuing anyway)");
        return Ok(());
    }

    let available_gb = free_bytes / (1024 * 1024 * 1024);
    let min_bytes = min_gb * 1024 * 1024 * 1024;

    if free_bytes < min_bytes {
        anyhow::bail!(
            "❌ Pre-flight check failed: Insufficient disk space\n\n\
             Available: {:.2} GB\n\
             Required:  {} GB\n\n\
             Possible solutions:\n\
             1. Free up disk space (delete unused files, clear caches)\n\
             2. Use a different working directory with more space\n\
             3. Skip this check with: --skip-preflight (not recommended)\n\
             4. Adjust minimum in node config: \"preflight\": {{ \"min_disk_space_gb\": <lower-value> }}\n",
            available_gb as f64,
            min_gb
        );
    }

    debug!(
        "✅ Disk space: {:.2} GB available (>= {} GB required)",
        available_gb as f64, min_gb
    );
    Ok(())
}

/// Check if specified ports are available
fn check_ports(ports: &[u16]) -> Result<()> {
    if ports.is_empty() {
        return Ok(());
    }

    for &port in ports {
        match std::net::TcpListener::bind(("127.0.0.1", port)) {
            Ok(_) => {}
            Err(_) => {
                #[cfg(unix)]
                let find_cmd = format!("lsof -i :{}", port);
                #[cfg(windows)]
                let find_cmd = format!("netstat -ano | findstr :{}", port);

                anyhow::bail!(
                    "❌ Pre-flight check failed: Port {} already in use\n\n\
                     Possible solutions:\n\
                     1. Stop the process using port {}\n\
                     2. Change the port in your node configuration\n\
                     3. Skip this check with: --skip-preflight (not recommended)\n\n\
                     Find process using port: {}\n",
                    port,
                    port,
                    find_cmd
                );
            }
        }
    }

    debug!("✅ Ports: {:?} free", ports);
    Ok(())
}

/// Check file system permissions
fn check_permissions() -> Result<()> {
    let logs_dir = Path::new("logs");
    if !logs_dir.exists() {
        std::fs::create_dir_all(logs_dir).with_context(|| {
            "❌ Pre-flight check failed: Cannot create logs/ directory\n\n\
             Possible solutions:\n\
             1. Check file system permissions in current directory\n\
             2. Run with appropriate user permissions\n\
             3. Skip this check with: --skip-preflight (not recommended)\n"
        })?;
    }

    let test_file = logs_dir.join(".write_test");
    std::fs::write(&test_file, "test").with_context(|| {
        "❌ Pre-flight check failed: Cannot write to logs/ directory\n\n\
         Possible solutions:\n\
         1. Check directory permissions: ls -la logs/\n\
         2. Ensure write access: chmod u+w logs/\n\
         3. Run with appropriate user permissions\n\
         4. Skip this check with: --skip-preflight (not recommended)\n"
    })?;

    let _ = std::fs::remove_file(&test_file);

    let models_dir = Path::new("models");
    if models_dir.exists() {
        std::fs::read_dir(models_dir).with_context(|| {
            "❌ Pre-flight check failed: Cannot read models/ directory\n\n\
             Possible solutions:\n\
             1. Check directory permissions: ls -la models/\n\
             2. Ensure read access: chmod u+r models/\n\
             3. Run with appropriate user permissions\n\
             4. Skip this check with: --skip-preflight (not recommended)\n"
        })?;
    }

    debug!("✅ Permissions: logs/ writable, models/ readable");
    Ok(())
}

/// Check GPU availability (simple check via environment variables)
fn check_gpu_availability() -> Result<()> {
    if std::env::var("CUDA_VISIBLE_DEVICES").is_ok() {
        info!("✅ GPU: CUDA detected (CUDA_VISIBLE_DEVICES set)");
        return Ok(());
    }

    #[cfg(unix)]
    {
        if which::which("nvidia-smi").is_ok() {
            info!("✅ GPU: NVIDIA GPU detected (nvidia-smi found)");
            return Ok(());
        }
    }

    #[cfg(target_os = "macos")]
    {
        if std::env::consts::ARCH == "aarch64" {
            info!("✅ GPU: Metal detected (Apple Silicon)");
            return Ok(());
        }
    }

    anyhow::bail!(
        "❌ Pre-flight check failed: GPU required but not detected\n\n\
         Possible solutions:\n\
         1. For NVIDIA GPUs: Set CUDA_VISIBLE_DEVICES environment variable\n\
         2. For NVIDIA GPUs: Install NVIDIA drivers and verify nvidia-smi works\n\
         3. For Apple Silicon: This check should pass automatically on M-series Macs\n\
         4. Remove --require-gpu flag if GPU is not needed\n\
         5. Skip this check with: --skip-preflight (not recommended)\n\n\
         Note: This is a simple detection check. For full GPU support, ensure\n\
         appropriate ML frameworks (CUDA, Metal, etc.) are properly configured.\n"
    );
}