stackpatrol 0.1.0

Single-binary Rust CLI that monitors a server and reports to the StackPatrol control plane.
use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    pub server_name: String,
    pub token: String,
    #[serde(default = "default_endpoint")]
    pub api_endpoint: String,
    #[serde(default)]
    pub daemon: DaemonConfig,
    #[serde(default)]
    pub docker: DockerProbeConfig,
    #[serde(default)]
    pub systemd: SystemdProbeConfig,
    #[serde(default)]
    pub resources: ResourceProbeConfig,
    #[serde(default)]
    pub ports: PortProbeConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
    /// How often the daemon emits a heartbeat. Clamped to >= 5s at runtime.
    #[serde(default = "default_heartbeat_secs")]
    pub heartbeat_interval_secs: u64,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        Self { heartbeat_interval_secs: default_heartbeat_secs() }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DockerProbeConfig {
    /// Compose project directories to watch. Each path must contain a `compose.yaml` /
    /// `docker-compose.yml`. Empty list = probe disabled.
    #[serde(default)]
    pub projects: Vec<PathBuf>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SystemdProbeConfig {
    /// systemd units to watch via `systemctl is-active`. Names should include the
    /// suffix (e.g. `nginx.service`, `postgresql.service`). Empty list = probe disabled.
    #[serde(default)]
    pub units: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceProbeConfig {
    /// Per-mount disk-usage threshold. Set to 100 to silence disk alerts entirely.
    #[serde(default = "default_disk_high")]
    pub disk_high_percent: u8,
    /// System memory-usage threshold. Set to 100 to silence.
    #[serde(default = "default_memory_high")]
    pub memory_high_percent: u8,
    /// Absolute 1-minute load threshold. 0 = auto (`2 × logical CPU count`).
    #[serde(default)]
    pub load_1m_high: f32,
}

impl Default for ResourceProbeConfig {
    fn default() -> Self {
        Self {
            disk_high_percent: default_disk_high(),
            memory_high_percent: default_memory_high(),
            load_1m_high: 0.0,
        }
    }
}

fn default_disk_high() -> u8 {
    90
}

fn default_memory_high() -> u8 {
    90
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PortProbeConfig {
    /// TCP targets to check each tick. Format: `host:port` (e.g. `localhost:5432`,
    /// `1.1.1.1:443`). Empty list = probe disabled.
    #[serde(default)]
    pub tcp: Vec<String>,
}

fn default_endpoint() -> String {
    "https://api.stackpatrol.dev".into()
}

fn default_heartbeat_secs() -> u64 {
    30
}

pub fn config_path() -> Result<PathBuf> {
    let dir = dirs::config_dir().context("could not resolve config dir")?;
    Ok(dir.join("stackpatrol").join("config.toml"))
}

impl Config {
    pub fn load() -> Result<Self> {
        let path = config_path()?;
        let raw = std::fs::read_to_string(&path)
            .with_context(|| format!("reading {}", path.display()))?;
        let cfg: Config = toml::from_str(&raw).context("parsing config TOML")?;
        Ok(cfg)
    }

    pub fn save(&self) -> Result<PathBuf> {
        let path = config_path()?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("creating {}", parent.display()))?;
        }
        let body = toml::to_string_pretty(self).context("serializing config")?;
        std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
        Ok(path)
    }
}