railwayapp 5.34.4

Interact with Railway via CLI
mod mongodb;
mod mysql;
mod postgres;
mod redis;
pub mod types;

use anyhow::Result;

use crate::controllers::database::DatabaseType;
use crate::controllers::exec::exec_in_container as exec_command_in_container;
use crate::controllers::ssh::keys::find_local_ssh_keys;

pub use types::DatabaseStats;

/// Preflight check for SSH-based DB stats collection. Runs locally only --
/// it catches the common "no SSH key" case before we spawn the SSH process,
/// so we can surface actionable guidance instead of a cryptic failure.
pub async fn preflight_db_stats_ssh() -> Result<(), String> {
    match find_local_ssh_keys().await {
        Ok(keys) if keys.is_empty() => Err(
            "no SSH keys found in your SSH agent or ~/.ssh/\n\n\
             generate one with `ssh-keygen -t ed25519`, then register it with `railway ssh keys add`"
                .to_string(),
        ),
        Ok(_) => Ok(()),
        Err(e) => Err(format!(
            "unable to read ~/.ssh: {e}\n  \
             ensure the directory is readable and contains a registered SSH key"
        )),
    }
}

/// Translate a raw db-stats fetch error into a user-facing message with an
/// actionable next step. SSH and per-database CLI failures surface very
/// differently, so we try to classify the common modes and fall back to the
/// raw error otherwise.
pub fn diagnose_db_stats_failure(err: &anyhow::Error, db_type: &DatabaseType) -> String {
    let raw = format!("{err:#}");
    let lower = raw.to_ascii_lowercase();

    let hint = if lower.contains("permission denied (publickey)")
        || lower.contains("permission denied, please try again")
        || (lower.contains("permission denied") && lower.contains("publickey"))
    {
        Some(
            "your SSH key isn't registered with Railway -- run `railway ssh keys add` \
             (or import from GitHub with `railway ssh keys github`)",
        )
    } else if lower.contains("no such file or directory") && lower.contains("ssh")
        || lower.contains("program not found")
        || lower.contains("command not found")
    {
        Some(
            "the `ssh` binary was not found on PATH -- install OpenSSH and retry \
             (macOS: preinstalled; Linux: `apt install openssh-client` / equivalent)",
        )
    } else if lower.contains("host key verification failed") {
        Some(
            "SSH host key verification failed -- remove the stale entry with \
             `ssh-keygen -R ssh.railway.com` and retry",
        )
    } else if lower.contains("could not resolve hostname")
        || lower.contains("temporary failure in name resolution")
    {
        Some("could not resolve ssh.railway.com -- check your network connection")
    } else if lower.contains("connection refused")
        || lower.contains("connection timed out")
        || lower.contains("network is unreachable")
    {
        Some("could not connect to ssh.railway.com -- check your network and firewall rules")
    } else if lower.contains("not found") && cli_tool_missing(db_type, &lower) {
        Some(match db_type {
            DatabaseType::PostgreSQL => {
                "this image does not ship `psql`; database stats need the official Railway \
                 Postgres image"
            }
            DatabaseType::Redis => {
                "this image does not ship `redis-cli`; database stats need the official Railway \
                 Redis image"
            }
            DatabaseType::MySQL => {
                "this image does not ship `mysql`; database stats need the official Railway \
                 MySQL image"
            }
            DatabaseType::MongoDB => {
                "this image does not ship `mongosh`; database stats need the official Railway \
                 MongoDB image"
            }
        })
    } else {
        None
    };

    match hint {
        Some(h) => format!("{raw}\n  {h}"),
        None => raw,
    }
}

fn cli_tool_missing(db_type: &DatabaseType, lower_err: &str) -> bool {
    let tool = match db_type {
        DatabaseType::PostgreSQL => "psql",
        DatabaseType::Redis => "redis-cli",
        DatabaseType::MySQL => "mysql",
        DatabaseType::MongoDB => "mongosh",
    };
    lower_err.contains(tool)
}

/// Fetch database-specific internal metrics by SSHing into the container
/// and running database CLI commands.
pub async fn fetch_db_stats(
    service_instance_id: &str,
    db_type: &DatabaseType,
) -> Result<DatabaseStats> {
    match db_type {
        DatabaseType::PostgreSQL => {
            let stats = postgres::fetch_postgres_stats(service_instance_id).await?;
            Ok(DatabaseStats::PostgreSQL(stats))
        }
        DatabaseType::Redis => {
            let stats = redis::fetch_redis_stats(service_instance_id).await?;
            Ok(DatabaseStats::Redis(stats))
        }
        DatabaseType::MySQL => {
            let stats = mysql::fetch_mysql_stats(service_instance_id).await?;
            Ok(DatabaseStats::MySQL(stats))
        }
        DatabaseType::MongoDB => {
            let stats = mongodb::fetch_mongo_stats(service_instance_id).await?;
            Ok(DatabaseStats::MongoDB(stats))
        }
    }
}

/// Split raw command output into sections by delimiter markers.
/// Returns a map of section_name -> section_content.
///
/// `pub(crate)`: also reused by `commands::postgres::pgbouncer`'s live pool
/// probe (`SHOW POOLS`/`SHOW STATS`/`SHOW SERVERS`), which follows the same
/// `echo '===NAME==='` delimiter convention as this module's own commands.
pub(crate) fn split_sections(output: &str) -> std::collections::HashMap<&str, &str> {
    let mut sections = std::collections::HashMap::new();
    let mut current_name: Option<&str> = None;
    let mut current_start = 0;

    // Walk the string by finding each line's byte range directly
    let mut pos = 0;
    for line in output.lines() {
        // Find where this line actually starts in the original string
        // (lines() may skip \r\n or \n, so advance pos past any line ending)
        let line_start = pos;
        pos += line.len();
        // Skip the line ending (\r\n or \n)
        if output.as_bytes().get(pos) == Some(&b'\r') {
            pos += 1;
        }
        if output.as_bytes().get(pos) == Some(&b'\n') {
            pos += 1;
        }

        if let Some(name) = line.strip_prefix("===").and_then(|s| s.strip_suffix("===")) {
            // Save previous section
            if let Some(prev) = current_name {
                let content = &output[current_start..line_start];
                sections.insert(prev, content.trim());
            }
            current_name = Some(name);
            current_start = pos;
        }
    }

    // Save last section
    if let Some(name) = current_name {
        let content = &output[current_start..];
        sections.insert(name, content.trim());
    }

    sections
}

/// Parse a simple integer from a string, returning 0 on failure.
///
/// `pub(crate)`: reused by `commands::postgres::pgbouncer`'s live pool probe.
pub(crate) fn parse_i64(s: &str) -> i64 {
    s.trim().parse().unwrap_or(0)
}

/// Parse a simple float from a string, returning 0.0 on failure.
fn parse_f64(s: &str) -> f64 {
    s.trim().parse().unwrap_or(0.0)
}