AkurAI Build
Menu

Public repository / 44903b72f606a648c72f939ee5299c590717c83f

AkurAI-Monitor / src/management.rs

Latest change dd5ceb09c5112682f7d0ab013d27ed6ac8d4d355 - feat: add monitor management API and CLI by Ólafur Búi Ólafsson

use axum::Json;
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use rusqlite::types::{Value as SqlValue, ValueRef};
use rusqlite::{OptionalExtension, params_from_iter};
use serde::Deserialize;
use serde_json::{Map, Value, json};

use crate::{alert, db};

#[derive(Clone, Copy)]
struct Resource {
    name: &'static str,
    table: &'static str,
    fields: &'static [&'static str],
    required: &'static [&'static str],
    timestamped: bool,
    ordered_by_ts: bool,
}

const HOST_FIELDS: &[&str] = &[
    "name",
    "address",
    "kind",
    "enabled",
    "created_at",
    "updated_at",
];
const APPLICATION_FIELDS: &[&str] = &[
    "host_id",
    "name",
    "service_name",
    "health_url",
    "enabled",
    "created_at",
    "updated_at",
];
const METRIC_FIELDS: &[&str] = &["host_id", "application_id", "name", "value", "ts"];
const LOG_FIELDS: &[&str] = &["host_id", "application_id", "source", "line", "ts"];
const ALERT_FIELDS: &[&str] = &[
    "host_id",
    "application_id",
    "name",
    "metric_name",
    "operator",
    "threshold",
    "duration_secs",
    "enabled",
];
const RECIPIENT_FIELDS: &[&str] = &[
    "channel_id",
    "name",
    "target",
    "enabled",
    "created_at",
    "updated_at",
];
const CHANNEL_FIELDS: &[&str] = &[
    "name",
    "kind",
    "endpoint",
    "token_env",
    "enabled",
    "created_at",
    "updated_at",
];

fn resource(name: &str) -> Option<Resource> {
    Some(match name {
        "hosts" => Resource {
            name: "hosts",
            table: "hosts",
            fields: HOST_FIELDS,
            required: &["name", "address"],
            timestamped: true,
            ordered_by_ts: false,
        },
        "applications" => Resource {
            name: "applications",
            table: "applications",
            fields: APPLICATION_FIELDS,
            required: &["host_id", "name"],
            timestamped: true,
            ordered_by_ts: false,
        },
        "metrics" => Resource {
            name: "metrics",
            table: "metrics",
            fields: METRIC_FIELDS,
            required: &["name", "value"],
            timestamped: false,
            ordered_by_ts: true,
        },
        "logs" => Resource {
            name: "logs",
            table: "logs",
            fields: LOG_FIELDS,
            required: &["source", "line"],
            timestamped: false,
            ordered_by_ts: true,
        },
        "alerts" => Resource {
            name: "alerts",
            table: "alert_rules",
            fields: ALERT_FIELDS,
            required: &[
                "name",
                "metric_name",
                "operator",
                "threshold",
                "duration_secs",
            ],
            timestamped: false,
            ordered_by_ts: false,
        },
        "recipients" => Resource {
            name: "recipients",
            table: "recipients",
            fields: RECIPIENT_FIELDS,
            required: &["channel_id", "name", "target"],
            timestamped: true,
            ordered_by_ts: false,
        },
        "channels" => Resource {
            name: "channels",
            table: "channels",
            fields: CHANNEL_FIELDS,
            required: &["name", "kind", "endpoint"],
            timestamped: true,
            ordered_by_ts: false,
        },
        _ => return None,
    })
}

#[derive(Deserialize)]
pub struct ListQuery {
    limit: Option<usize>,
}

type ApiResponse = (StatusCode, Json<Value>);

pub async fn list(Path(name): Path<String>, Query(query): Query<ListQuery>) -> ApiResponse {
    let Some(resource) = resource(&name) else {
        return error(StatusCode::NOT_FOUND, "unknown resource");
    };
    let limit = query.limit.unwrap_or(200).clamp(1, 1000);
    let columns = select_columns(resource);
    let order = if resource.ordered_by_ts {
        "ts DESC"
    } else {
        "id DESC"
    };
    let sql = format!(
        "SELECT {columns} FROM {} ORDER BY {order} LIMIT ?1",
        resource.table
    );

    let result = db::with_db(|conn| {
        let mut statement = conn.prepare(&sql)?;
        let rows = statement.query_map([limit as i64], |row| row_json(row, resource))?;
        rows.collect::<rusqlite::Result<Vec<_>>>()
    });

    match result {
        Ok(items) => (
            StatusCode::OK,
            Json(json!({"resource": resource.name, "count": items.len(), "items": items})),
        ),
        Err(_) => error(StatusCode::INTERNAL_SERVER_ERROR, "database query failed"),
    }
}

pub async fn get(Path((name, id)): Path<(String, i64)>) -> ApiResponse {
    let Some(resource) = resource(&name) else {
        return error(StatusCode::NOT_FOUND, "unknown resource");
    };
    let sql = format!(
        "SELECT {} FROM {} WHERE id = ?1",
        select_columns(resource),
        resource.table
    );
    let result = db::with_db(|conn| {
        conn.query_row(&sql, [id], |row| row_json(row, resource))
            .optional()
    });

    match result {
        Ok(Some(item)) => (
            StatusCode::OK,
            Json(json!({"resource": resource.name, "item": item})),
        ),
        Ok(None) => error(StatusCode::NOT_FOUND, "record not found"),
        Err(_) => error(StatusCode::INTERNAL_SERVER_ERROR, "database query failed"),
    }
}

pub async fn create(Path(name): Path<String>, Json(body): Json<Value>) -> ApiResponse {
    let Some(resource) = resource(&name) else {
        return error(StatusCode::NOT_FOUND, "unknown resource");
    };
    let Ok(mut fields) = object_body(body) else {
        return error(StatusCode::BAD_REQUEST, "request body must be an object");
    };
    apply_defaults(resource, &mut fields, true);
    if let Err(message) = validate(resource, &fields, true) {
        return error(StatusCode::BAD_REQUEST, message);
    }
    if resource.name == "channels"
        && let Err(message) = validate_channel_config(&fields, None)
    {
        return error(StatusCode::BAD_REQUEST, message);
    }

    let entries = ordered_entries(resource, &fields);
    let columns = entries
        .iter()
        .map(|(name, _)| *name)
        .collect::<Vec<_>>()
        .join(", ");
    let placeholders = (1..=entries.len())
        .map(|index| format!("?{index}"))
        .collect::<Vec<_>>()
        .join(", ");
    let values = match entries
        .iter()
        .map(|(_, value)| sql_value(value))
        .collect::<Result<Vec<_>, _>>()
    {
        Ok(values) => values,
        Err(message) => return error(StatusCode::BAD_REQUEST, message),
    };
    let sql = format!(
        "INSERT INTO {} ({columns}) VALUES ({placeholders})",
        resource.table
    );
    let result = db::with_db(|conn| {
        conn.execute(&sql, params_from_iter(values.iter()))?;
        Ok::<_, rusqlite::Error>(conn.last_insert_rowid())
    });

    match result {
        Ok(id) => get(Path((name, id))).await,
        Err(error_value) => database_error(error_value),
    }
}

pub async fn update(Path((name, id)): Path<(String, i64)>, Json(body): Json<Value>) -> ApiResponse {
    let Some(resource) = resource(&name) else {
        return error(StatusCode::NOT_FOUND, "unknown resource");
    };
    let Ok(mut fields) = object_body(body) else {
        return error(StatusCode::BAD_REQUEST, "request body must be an object");
    };
    if resource.name == "hosts"
        && fields
            .get("name")
            .is_some_and(|value| value.as_str() != Some("local"))
    {
        match is_local_host(id) {
            Ok(true) => return error(StatusCode::CONFLICT, "the local host cannot be renamed"),
            Ok(false) => {}
            Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "database query failed"),
        }
    }
    apply_defaults(resource, &mut fields, false);
    if fields.is_empty() {
        return error(StatusCode::BAD_REQUEST, "no fields to update");
    }
    if let Err(message) = validate(resource, &fields, false) {
        return error(StatusCode::BAD_REQUEST, message);
    }
    if resource.name == "channels"
        && let Err(message) = validate_channel_config(&fields, Some(id))
    {
        return error(StatusCode::BAD_REQUEST, message);
    }

    let entries = ordered_entries(resource, &fields);
    let assignments = entries
        .iter()
        .enumerate()
        .map(|(index, (name, _))| format!("{name} = ?{}", index + 1))
        .collect::<Vec<_>>()
        .join(", ");
    let mut values = match entries
        .iter()
        .map(|(_, value)| sql_value(value))
        .collect::<Result<Vec<_>, _>>()
    {
        Ok(values) => values,
        Err(message) => return error(StatusCode::BAD_REQUEST, message),
    };
    values.push(SqlValue::Integer(id));
    let sql = format!(
        "UPDATE {} SET {assignments} WHERE id = ?{}",
        resource.table,
        values.len()
    );
    let result = db::with_db(|conn| conn.execute(&sql, params_from_iter(values.iter())));

    match result {
        Ok(0) => error(StatusCode::NOT_FOUND, "record not found"),
        Ok(_) => get(Path((name, id))).await,
        Err(error_value) => database_error(error_value),
    }
}

pub async fn delete(Path((name, id)): Path<(String, i64)>) -> ApiResponse {
    let Some(resource) = resource(&name) else {
        return error(StatusCode::NOT_FOUND, "unknown resource");
    };
    if resource.name == "hosts" {
        match is_local_host(id) {
            Ok(true) => return error(StatusCode::CONFLICT, "the local host cannot be deleted"),
            Ok(false) => {}
            Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "database query failed"),
        }
    }
    let sql = format!("DELETE FROM {} WHERE id = ?1", resource.table);
    match db::with_db(|conn| conn.execute(&sql, [id])) {
        Ok(0) => error(StatusCode::NOT_FOUND, "record not found"),
        Ok(_) => (
            StatusCode::OK,
            Json(json!({"deleted": id, "resource": resource.name})),
        ),
        Err(error_value) => database_error(error_value),
    }
}

pub async fn test_channel(Path(id): Path<i64>) -> ApiResponse {
    match alert::test_channel(id).await {
        Ok(sent) => (
            StatusCode::OK,
            Json(json!({"channel_id": id, "sent": sent})),
        ),
        Err(message) => error(StatusCode::BAD_GATEWAY, &message),
    }
}

fn is_local_host(id: i64) -> rusqlite::Result<bool> {
    db::with_db(|conn| {
        conn.query_row("SELECT name FROM hosts WHERE id = ?1", [id], |row| {
            row.get::<_, String>(0)
        })
        .optional()
        .map(|name| name.as_deref() == Some("local"))
    })
}

fn select_columns(resource: Resource) -> String {
    std::iter::once("id")
        .chain(resource.fields.iter().copied())
        .collect::<Vec<_>>()
        .join(", ")
}

fn ordered_entries(resource: Resource, fields: &Map<String, Value>) -> Vec<(&'static str, &Value)> {
    resource
        .fields
        .iter()
        .filter_map(|field| fields.get(*field).map(|value| (*field, value)))
        .collect()
}

fn row_json(row: &rusqlite::Row<'_>, resource: Resource) -> rusqlite::Result<Value> {
    let mut object = Map::new();
    object.insert("id".to_string(), value_ref(row.get_ref(0)?));
    for (index, field) in resource.fields.iter().enumerate() {
        object.insert((*field).to_string(), value_ref(row.get_ref(index + 1)?));
    }
    Ok(Value::Object(object))
}

fn value_ref(value: ValueRef<'_>) -> Value {
    match value {
        ValueRef::Null => Value::Null,
        ValueRef::Integer(value) => Value::from(value),
        ValueRef::Real(value) => Value::from(value),
        ValueRef::Text(value) => Value::String(String::from_utf8_lossy(value).into_owned()),
        ValueRef::Blob(_) => Value::Null,
    }
}

fn sql_value(value: &Value) -> Result<SqlValue, &'static str> {
    match value {
        Value::Null => Ok(SqlValue::Null),
        Value::Bool(value) => Ok(SqlValue::Integer(i64::from(*value))),
        Value::Number(value) => value
            .as_i64()
            .map(SqlValue::Integer)
            .or_else(|| value.as_f64().map(SqlValue::Real))
            .ok_or("number is outside SQLite range"),
        Value::String(value) => Ok(SqlValue::Text(value.clone())),
        Value::Array(_) | Value::Object(_) => Err("field values must be scalar"),
    }
}

fn object_body(body: Value) -> Result<Map<String, Value>, ()> {
    body.as_object().cloned().ok_or(())
}

fn apply_defaults(resource: Resource, fields: &mut Map<String, Value>, create: bool) {
    let now = chrono::Utc::now().timestamp();
    if create {
        if resource.timestamped {
            fields.insert("created_at".to_string(), Value::from(now));
            fields.insert("updated_at".to_string(), Value::from(now));
        }
        if resource.ordered_by_ts {
            fields
                .entry("ts".to_string())
                .or_insert_with(|| Value::from(now));
        }
        if resource.fields.contains(&"enabled") {
            fields
                .entry("enabled".to_string())
                .or_insert(Value::from(true));
        }
        if resource.name == "hosts" {
            fields
                .entry("kind".to_string())
                .or_insert(Value::from("linux"));
        }
    } else if resource.timestamped {
        fields.insert("updated_at".to_string(), Value::from(now));
    }
}

fn validate(
    resource: Resource,
    fields: &Map<String, Value>,
    create: bool,
) -> Result<(), &'static str> {
    if let Some(field) = fields
        .keys()
        .find(|field| !resource.fields.contains(&field.as_str()))
    {
        let _ = field;
        return Err("request contains an unknown field");
    }
    if create
        && resource
            .required
            .iter()
            .any(|field| !fields.contains_key(*field) || fields[*field].is_null())
    {
        return Err("request is missing a required field");
    }
    if resource
        .required
        .iter()
        .any(|field| fields.get(*field).is_some_and(Value::is_null))
    {
        return Err("required fields cannot be null");
    }
    if fields
        .values()
        .any(|value| matches!(value, Value::Array(_) | Value::Object(_)))
    {
        return Err("field values must be scalar");
    }
    for (field, value) in fields {
        if value.is_null() {
            continue;
        }
        match field.as_str() {
            "enabled" => {
                let valid = value.is_boolean()
                    || value
                        .as_i64()
                        .is_some_and(|enabled| matches!(enabled, 0 | 1));
                if !valid {
                    return Err("enabled must be a boolean");
                }
            }
            "host_id" | "application_id" | "channel_id" | "duration_secs" => {
                if !value.as_i64().is_some_and(|number| number > 0) {
                    return Err("IDs and durations must be positive integers");
                }
            }
            "created_at" | "updated_at" | "ts" => {
                if value.as_i64().is_none() {
                    return Err("timestamps must be integers");
                }
            }
            "value" | "threshold" => {
                if !value.as_f64().is_some_and(f64::is_finite) {
                    return Err("metric values and thresholds must be finite numbers");
                }
            }
            _ if !value.is_string() => return Err("text fields must contain strings"),
            _ => {}
        }
    }
    if let Some(Value::String(operator)) = fields.get("operator")
        && !matches!(operator.as_str(), "gt" | "lt" | "eq")
    {
        return Err("operator must be gt, lt, or eq");
    }
    if let Some(Value::String(kind)) = fields.get("kind")
        && resource.name == "channels"
        && !matches!(kind.as_str(), "webhook" | "pi-bun")
    {
        return Err("channel kind must be webhook or pi-bun");
    }
    if let Some(Value::String(endpoint)) = fields.get("endpoint")
        && !endpoint.starts_with("http://")
        && !endpoint.starts_with("https://")
    {
        return Err("endpoint must be an HTTP or HTTPS URL");
    }
    Ok(())
}

fn validate_channel_config(
    fields: &Map<String, Value>,
    existing_id: Option<i64>,
) -> Result<(), &'static str> {
    let existing = existing_id
        .map(|id| {
            db::with_db(|conn| {
                conn.query_row(
                    "SELECT kind, endpoint, token_env FROM channels WHERE id = ?1",
                    [id],
                    |row| {
                        Ok((
                            row.get::<_, String>(0)?,
                            row.get::<_, String>(1)?,
                            row.get::<_, Option<String>>(2)?,
                        ))
                    },
                )
                .optional()
            })
        })
        .transpose()
        .map_err(|_| "failed to read channel")?
        .flatten();
    let kind = fields
        .get("kind")
        .and_then(Value::as_str)
        .or_else(|| existing.as_ref().map(|channel| channel.0.as_str()))
        .ok_or("channel kind is required")?;
    let endpoint = fields
        .get("endpoint")
        .and_then(Value::as_str)
        .or_else(|| existing.as_ref().map(|channel| channel.1.as_str()))
        .ok_or("channel endpoint is required")?;
    let token_env = match fields.get("token_env") {
        Some(Value::Null) => None,
        Some(Value::String(value)) => Some(value.as_str()),
        Some(_) => return Err("token_env must be a string or null"),
        None => existing.as_ref().and_then(|channel| channel.2.as_deref()),
    };
    if token_env.is_some_and(|name| !name.starts_with("MONITOR_CHANNEL_TOKEN_")) {
        return Err("channel tokens must use a MONITOR_CHANNEL_TOKEN_ environment variable");
    }

    let url = reqwest::Url::parse(endpoint).map_err(|_| "channel endpoint is not a valid URL")?;
    if !url.username().is_empty() || url.password().is_some() {
        return Err("channel endpoint cannot contain credentials");
    }
    match kind {
        "webhook" if url.scheme() != "https" => return Err("webhook endpoint must use HTTPS"),
        "pi-bun" => {
            if !matches!(url.scheme(), "http" | "https") || url.host_str() != Some("100.88.0.9") {
                return Err("pi-bun endpoint must use Titan's 100.88.0.9 mesh address");
            }
            if token_env.is_none() {
                return Err("pi-bun channel requires token_env");
            }
        }
        "webhook" => {}
        _ => return Err("channel kind must be webhook or pi-bun"),
    }
    Ok(())
}

fn database_error(error_value: rusqlite::Error) -> ApiResponse {
    match error_value {
        rusqlite::Error::SqliteFailure(sqlite_error, _)
            if sqlite_error.code == rusqlite::ErrorCode::ConstraintViolation =>
        {
            error(StatusCode::CONFLICT, "record conflicts with existing data")
        }
        _ => error(
            StatusCode::INTERNAL_SERVER_ERROR,
            "database operation failed",
        ),
    }
}

fn error(status: StatusCode, message: &str) -> ApiResponse {
    (status, Json(json!({"error": message})))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validates_channel_transport_and_endpoint() {
        let channel = resource("channels").unwrap();
        let valid = json!({
            "name": "agents",
            "kind": "pi-bun",
            "endpoint": "http://100.88.0.9:4173",
            "token_env": "MONITOR_CHANNEL_TOKEN_PI_BUN",
        })
        .as_object()
        .unwrap()
        .clone();
        assert_eq!(validate(channel, &valid, true), Ok(()));
        assert_eq!(validate_channel_config(&valid, None), Ok(()));

        let mut invalid_kind = valid.clone();
        invalid_kind.insert("kind".to_string(), Value::from("shell"));
        assert_eq!(
            validate(channel, &invalid_kind, true),
            Err("channel kind must be webhook or pi-bun")
        );

        let mut wrong_type = valid.clone();
        wrong_type.insert("kind".to_string(), Value::from(true));
        assert_eq!(
            validate(channel, &wrong_type, true),
            Err("text fields must contain strings")
        );

        let mut wrong_pi_host = valid.clone();
        wrong_pi_host.insert("endpoint".to_string(), Value::from("http://127.0.0.1:4173"));
        assert_eq!(
            validate_channel_config(&wrong_pi_host, None),
            Err("pi-bun endpoint must use Titan's 100.88.0.9 mesh address")
        );

        let mut wrong_token_name = valid.clone();
        wrong_token_name.insert("token_env".to_string(), Value::from("WORKBENCH_TOKEN"));
        assert_eq!(
            validate_channel_config(&wrong_token_name, None),
            Err("channel tokens must use a MONITOR_CHANNEL_TOKEN_ environment variable")
        );

        let webhook = json!({
            "name": "webhook",
            "kind": "webhook",
            "endpoint": "http://127.0.0.1:18801/alert"
        })
        .as_object()
        .unwrap()
        .clone();
        assert_eq!(
            validate_channel_config(&webhook, None),
            Err("webhook endpoint must use HTTPS")
        );

        let mut invalid_endpoint = valid;
        invalid_endpoint.insert("endpoint".to_string(), Value::from("file:///tmp/socket"));
        assert_eq!(
            validate(channel, &invalid_endpoint, true),
            Err("endpoint must be an HTTP or HTTPS URL")
        );
    }

    #[test]
    fn validates_alert_operator_and_required_fields() {
        let alert = resource("alerts").unwrap();
        let mut fields = json!({
            "name": "High load",
            "metric_name": "load.5m",
            "operator": "gt",
            "threshold": 4,
            "duration_secs": 300
        })
        .as_object()
        .unwrap()
        .clone();
        assert_eq!(validate(alert, &fields, true), Ok(()));

        fields.insert("operator".to_string(), Value::from("contains"));
        assert_eq!(
            validate(alert, &fields, true),
            Err("operator must be gt, lt, or eq")
        );

        fields.insert("operator".to_string(), Value::from("gt"));
        fields.insert("duration_secs".to_string(), Value::from(0));
        assert_eq!(
            validate(alert, &fields, true),
            Err("IDs and durations must be positive integers")
        );
        fields.insert("duration_secs".to_string(), Value::from(300));
        fields.remove("metric_name");
        assert_eq!(
            validate(alert, &fields, true),
            Err("request is missing a required field")
        );
    }
}