isb-daemon 1.10.0

The isb serve daemon: the tools behind its API, MCP server and web UI. Use the `isb` crate.
Documentation
//! The `notification_*` tools over [`crate::notify::Notifier`], and
//! `metrics_query` over [`crate::metrics_history::History`].

use serde::Deserialize;
use serde_json::{Value, json};

use super::{arg_org, args, caller_name, obj};
use crate::app::Apps;
use crate::error::{Error, Result};
use crate::metrics_history::{History, Query};
use crate::notify::{Channel, Notifier, Provider, Rule, Settings};
use crate::server::{Caller, Registry, Tool};

const PROVIDER_DESC: &str = "Where messages go, by `type`: {\"type\": \"webhook\", \"url_secret\": NAME, \"signing_secret\": NAME?} (JSON POST; with a signing secret, X-Isb-Signature: sha256=HMAC of the body), {\"type\": \"slack\", \"url_secret\": NAME} (an incoming-webhook URL), {\"type\": \"discord\", \"url_secret\": NAME}, {\"type\": \"telegram\", \"token_secret\": NAME, \"chat_id\": \"-100...\"}, {\"type\": \"email\", \"host\", \"port\"?, \"tls\": \"starttls\"|\"tls\"|\"none\", \"username\"?, \"password_secret\"?, \"from\", \"to\": [..]}. Every URL, token and password is an org secret, named here (secret_create first), never a value.";

const RULES_DESC: &str = "Which events: a list of {\"events\": [glob, ...] (deploy.*, health.*, backup.*, job.*, cert.*, *.failed, *), \"projects\"?: [..], \"apps\"?: [..], \"stacks\"?: [..]}; any rule matching sends. Default: [{\"events\": [\"*\"]}].";

fn channel_json(c: &Channel) -> Value {
    serde_json::to_value(c).unwrap_or_default()
}

#[expect(
    clippy::too_many_lines,
    reason = "predates the lint ratchet; split it when next changed"
)]
pub fn register(r: &mut Registry, n: Notifier, h: History, apps: Apps) -> Result<()> {
    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
    let write = json!({"destructiveHint": false, "openWorldHint": false});
    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
    let send = json!({"destructiveHint": false, "openWorldHint": true});

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_channel_create",
            "Add a notification channel to the org: a destination (webhook, Slack, Discord, Telegram, email) and rules choosing which events it hears about. Only the org's own events reach it. Its secrets must exist.",
            obj(
                json!({
                    "name": {"type": "string", "description": "[a-z0-9-], starting with a letter, at most 40."},
                    "provider": {"type": "object", "description": PROVIDER_DESC},
                    "rules": {"type": "array", "items": {"type": "object"}, "description": RULES_DESC},
                    "enabled": {"type": "boolean", "description": "Default true."}
                }),
                &["name", "provider"],
            ),
            move |a, _c| {
                #[derive(Deserialize)]
                #[serde(deny_unknown_fields)]
                struct A {
                    #[serde(default)]
                    org: Option<String>,
                    name: String,
                    provider: Provider,
                    rules: Option<Vec<Rule>>,
                    enabled: Option<bool>,
                }
                let org = arg_org(&a)?;
                let a: A = args(a)?;
                let _ = a.org;
                let c = n2.create(
                    &org,
                    Channel {
                        name: a.name,
                        provider: a.provider,
                        enabled: a.enabled.unwrap_or(true),
                        rules: a.rules.unwrap_or_else(|| vec![Rule::default()]),
                        created_at: 0,
                        updated_at: 0,
                    },
                )?;
                Ok(channel_json(&c))
            },
        )
        .title("Create a notification channel")
        .annotations(write.clone()),
    )?;

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_channel_list",
            "The org's notification channels: provider (secret names, never values), rules, enabled, and each one's last delivery.",
            obj(json!({}), &[]),
            move |a, _c| {
                let org = arg_org(&a)?;
                let out: Vec<Value> = n2
                    .list(&org)?
                    .iter()
                    .map(|c| {
                        let mut v = channel_json(c);
                        let last = n2.deliveries(&org, &c.name).ok().and_then(|d| d.into_iter().next());
                        v["last_delivery"] = serde_json::to_value(last).unwrap_or_default();
                        v
                    })
                    .collect();
                Ok(json!({"channels": out}))
            },
        )
        .title("List notification channels")
        .annotations(ro.clone()),
    )?;

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_channel_get",
            "One notification channel.",
            obj(json!({"name": {"type": "string"}}), &["name"]),
            move |a, _c| {
                let org = arg_org(&a)?;
                let name = a["name"].as_str().unwrap_or_default().to_string();
                Ok(channel_json(&n2.get(&org, &name)?))
            },
        )
        .title("Get a notification channel")
        .annotations(ro.clone()),
    )?;

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_channel_update",
            "Change a notification channel: its provider, its rules (the list is replaced), or enabled. Fields left out are kept.",
            obj(
                json!({
                    "name": {"type": "string"},
                    "provider": {"type": "object", "description": PROVIDER_DESC},
                    "rules": {"type": "array", "items": {"type": "object"}, "description": RULES_DESC},
                    "enabled": {"type": "boolean"}
                }),
                &["name"],
            ),
            move |a, _c| {
                #[derive(Deserialize)]
                #[serde(deny_unknown_fields)]
                struct A {
                    #[serde(default)]
                    org: Option<String>,
                    name: String,
                    provider: Option<Provider>,
                    rules: Option<Vec<Rule>>,
                    enabled: Option<bool>,
                }
                let org = arg_org(&a)?;
                let a: A = args(a)?;
                let _ = a.org;
                Ok(channel_json(&n2.update(
                    &org, &a.name, a.provider, a.rules, a.enabled,
                )?))
            },
        )
        .title("Update a notification channel")
        .annotations(write.clone()),
    )?;

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_channel_delete",
            "Remove a notification channel and its delivery log (its secrets stay).",
            obj(json!({"name": {"type": "string"}}), &["name"]),
            move |a, _c| {
                let org = arg_org(&a)?;
                let name = a["name"].as_str().unwrap_or_default().to_string();
                n2.delete(&org, &name)?;
                Ok(json!({"ok": true}))
            },
        )
        .title("Delete a notification channel")
        .annotations(destructive.clone()),
    )?;

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_test",
            "Send a test message to a channel now (once, no retries) and report the outcome: status sent or failed, the HTTP status, the error. It is logged with the channel's deliveries.",
            obj(json!({"name": {"type": "string"}}), &["name"]),
            move |a, c: &Caller| {
                let org = arg_org(&a)?;
                let name = a["name"].as_str().unwrap_or_default().to_string();
                Ok(serde_json::to_value(n2.test(&org, &name, &caller_name(c))?)?)
            },
        )
        .title("Test a notification channel")
        .annotations(send.clone()),
    )?;

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_deliveries",
            "A channel's recent deliveries, newest first (the last 50): event kind and number, status (queued, retrying, sent, failed, dropped, skipped), attempts, HTTP status, error.",
            obj(
                json!({
                    "name": {"type": "string"},
                    "limit": {"type": "integer", "minimum": 1, "maximum": 50}
                }),
                &["name"],
            ),
            move |a, _c| {
                let org = arg_org(&a)?;
                let name = a["name"].as_str().unwrap_or_default().to_string();
                let limit = a["limit"].as_u64().unwrap_or(50) as usize;
                let d: Vec<_> = n2.deliveries(&org, &name)?.into_iter().take(limit).collect();
                Ok(json!({"deliveries": d}))
            },
        )
        .title("Notification deliveries")
        .annotations(ro.clone()),
    )?;

    let n2 = n.clone();
    r.register(
        Tool::new(
            "notification_settings",
            "Server-wide notification settings (platform admins). allow_private_targets lets channels reach loopback, private (RFC 1918, ULA), link-local and CGNAT addresses, which are refused by default so a channel cannot reach into the host's network. Pass a field to change it; returns the settings.",
            obj(
                json!({"allow_private_targets": {"type": "boolean"}}),
                &[],
            ),
            move |a, c: &Caller| {
                if let Some(v) = a.get("allow_private_targets").and_then(Value::as_bool) {
                    n2.set_settings(Settings {
                        allow_private_targets: v,
                    })?;
                    eprintln!(
                        "isb serve: notify: private targets {} by {}",
                        if v { "allowed" } else { "refused" },
                        caller_name(c)
                    );
                }
                Ok(serde_json::to_value(n2.settings())?)
            },
        )
        .title("Notification settings")
        .annotations(write.clone()),
    )?;

    r.register(
        Tool::new(
            "metrics_query",
            "Metrics history of the org's instances for charts: cpu (percent of one core), memory (bytes), net_rx, net_tx, disk_read, disk_write (bytes per second). Choose an app, a stack (and service), or one instance. Kept 24 h at 10 s, 7 d at 1 min, 30 d at 10 min; `step` is widened to the tier that still holds `from`, and to at most 2000 points. With `aggregate` (sum, avg, max, min) the instances are combined bucket by bucket (a service's replicas); without, one series per instance. Points are [unix seconds, value].",
            obj(
                json!({
                    "metric": {"type": "string", "enum": ["cpu", "memory", "net_rx", "net_tx", "disk_read", "disk_write"]},
                    "app": {"type": "string", "description": "An app: its service in its project environment's stack."},
                    "stack": {"type": "string"},
                    "service": {"type": "string"},
                    "instance": {"type": "string"},
                    "range": {"type": "string", "description": "How far back from `to`, e.g. 1h, 24h, 7d (default 1h)."},
                    "from": {"type": "integer", "minimum": 0, "description": "Unix seconds; overrides range."},
                    "to": {"type": "integer", "minimum": 0, "description": "Unix seconds (default now)."},
                    "step": {"type": "integer", "minimum": 0, "description": "Seconds per point (default: the tier's)."},
                    "aggregate": {"type": "string", "enum": ["sum", "avg", "max", "min"]}
                }),
                &["metric"],
            ),
            move |a, _c| {
                #[derive(Deserialize)]
                #[serde(deny_unknown_fields)]
                struct A {
                    #[serde(default)]
                    org: Option<String>,
                    metric: String,
                    app: Option<String>,
                    stack: Option<String>,
                    service: Option<String>,
                    instance: Option<String>,
                    range: Option<String>,
                    from: Option<u64>,
                    to: Option<u64>,
                    #[serde(default)]
                    step: u64,
                    aggregate: Option<String>,
                }
                let org = arg_org(&a)?;
                let a: A = args(a)?;
                let _ = a.org;
                let (mut stack, mut service) = (a.stack, a.service);
                if let Some(app) = &a.app {
                    if stack.is_some() || service.is_some() {
                        return Err(Error::invalid("app, or stack and service, not both"));
                    }
                    let spec = apps.get(&org, app)?.spec;
                    stack = Some(spec.stack()?);
                    service = Some(spec.name);
                }
                let now = crate::stack::controller::now_ms() / 1000;
                let to = a.to.unwrap_or(now + 1);
                let from = match (a.from, &a.range) {
                    (Some(f), _) => f,
                    (None, Some(r)) => to.saturating_sub(
                        crate::parse_duration(r)
                            .map_err(|e| Error::invalid(format!("range: {e}")))?
                            .as_secs(),
                    ),
                    (None, None) => to.saturating_sub(3600),
                };
                if from >= to {
                    return Err(Error::invalid("from must be before to"));
                }
                let ans = h.query(
                    &org,
                    &Query {
                        metric: a.metric,
                        stack,
                        service,
                        instance: a.instance,
                        from,
                        to,
                        step: a.step,
                        aggregate: a.aggregate,
                    },
                )?;
                Ok(serde_json::to_value(ans)?)
            },
        )
        .title("Query metrics history")
        .annotations(ro.clone()),
    )?;
    Ok(())
}