pipedash-plugin-api 0.9.0

Plugin API for Pipedash - CI/CD pipeline dashboard
Documentation
use std::collections::HashMap;

use reqwest::Url;
use serde::{
    Deserialize,
    Serialize,
};

use crate::error::{
    PluginError,
    PluginResult,
};
use crate::schema::{
    ConfigField,
    ConfigFieldType,
    ConfigSection,
};

pub const STATUS_PAGE_URL_KEY: &str = "status_page_url";
pub const CONTROLLER_HEALTH_FEATURE: &str = "controller_health";

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum HealthLevel {
    Operational,
    Maintenance,
    Degraded,
    PartialOutage,
    MajorOutage,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentHealth {
    pub name: String,
    pub level: HealthLevel,
    pub detail: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ComponentFilter {
    All,
    Ids(Vec<String>),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FeedFormat {
    Statuspage,
    StatusIo,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusSource {
    pub name: String,
    pub page_url: Url,
    pub feed_url: Url,
    pub format: FeedFormat,
    pub components: ComponentFilter,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientOptions {
    pub origin: Option<Url>,
    pub accept_invalid_certs: bool,
}

pub fn status_page_field() -> ConfigField {
    ConfigField {
        key: STATUS_PAGE_URL_KEY.to_string(),
        label: "Status page URL (optional)".to_string(),
        description: Some(
            "Statuspage address for this instance, for example https://status.example.com. \
             Pipedash reads its /api/v2/summary.json."
                .to_string(),
        ),
        field_type: ConfigFieldType::Text,
        required: false,
        default_value: None,
        options: None,
        validation_regex: Some(
            r"^(https://[^\s/?#@]+|http://(127\.0\.0\.1|\[::1\])(:[0-9]+)?)(/[^\s?#]*)?$"
                .to_string(),
        ),
        validation_message: Some(
            "Must be an https URL without credentials, query or fragment".to_string(),
        ),
        section: ConfigSection::Settings,
    }
}

pub fn custom_status_source(
    config: &HashMap<String, String>,
) -> PluginResult<Option<StatusSource>> {
    let Some(raw) = config
        .get(STATUS_PAGE_URL_KEY)
        .map(|v| v.trim())
        .filter(|v| !v.is_empty())
    else {
        return Ok(None);
    };

    let invalid = || {
        PluginError::InvalidConfig(
            "status_page_url must be an https URL with a host and no credentials, query or \
             fragment"
                .to_string(),
        )
    };

    let mut page_url = Url::parse(raw).map_err(|_| invalid())?;
    let allowed_scheme = match page_url.scheme() {
        "https" => true,
        "http" => is_literal_loopback(&page_url),
        _ => false,
    };
    if !allowed_scheme
        || page_url.host_str().is_none_or(str::is_empty)
        || !page_url.username().is_empty()
        || page_url.password().is_some()
        || page_url.query().is_some()
        || page_url.fragment().is_some()
    {
        return Err(invalid());
    }

    if !page_url.path().ends_with('/') {
        let path = format!("{}/", page_url.path());
        page_url.set_path(&path);
    }

    let feed_url = page_url
        .join("api/v2/summary.json")
        .map_err(|_| invalid())?;
    let name = page_url.host_str().unwrap_or_default().to_string();

    Ok(Some(StatusSource {
        name,
        page_url,
        feed_url,
        format: FeedFormat::Statuspage,
        components: ComponentFilter::All,
    }))
}

fn is_literal_loopback(url: &Url) -> bool {
    matches!(url.host_str(), Some("127.0.0.1" | "[::1]"))
}

pub fn collect_controller_health(
    checks: Vec<PluginResult<Option<ComponentHealth>>>,
) -> PluginResult<Vec<ComponentHealth>> {
    let mut components = Vec::new();
    let mut denied = None;
    let mut missing = None;
    let total = checks.len();
    let mut missing_count = 0;

    for check in checks {
        match check {
            Ok(component) => components.extend(component),
            Err(PluginError::PermissionDenied(message)) => {
                denied.get_or_insert(message);
            }
            Err(PluginError::PipelineNotFound(message)) => {
                missing_count += 1;
                missing.get_or_insert(message);
            }
            Err(error) => return Err(error),
        }
    }

    if let Some(message) = denied {
        return Err(PluginError::PermissionDenied(message));
    }
    match missing {
        Some(message) if missing_count == total => Err(PluginError::PipelineNotFound(message)),
        _ => Ok(components),
    }
}

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

    fn source_for(url: &str) -> PluginResult<Option<StatusSource>> {
        let mut config = HashMap::new();
        config.insert(STATUS_PAGE_URL_KEY.to_string(), url.to_string());
        custom_status_source(&config)
    }

    fn controller(name: &str) -> ComponentHealth {
        ComponentHealth {
            name: name.to_string(),
            level: HealthLevel::MajorOutage,
            detail: Some("scaled to zero".to_string()),
        }
    }

    #[test]
    fn controller_health_leaves_out_healthy_and_single_missing_controllers() {
        let result = collect_controller_health(vec![
            Ok(None),
            Ok(Some(controller("kustomize-controller"))),
            Err(PluginError::PipelineNotFound("helm-controller".into())),
        ]);

        assert_eq!(result.unwrap(), vec![controller("kustomize-controller")]);
    }

    #[test]
    fn any_denied_controller_makes_the_check_denied() {
        let result = collect_controller_health(vec![
            Ok(Some(controller("kustomize-controller"))),
            Err(PluginError::PermissionDenied("forbidden".into())),
        ]);

        assert!(matches!(result, Err(PluginError::PermissionDenied(_))));
    }

    #[test]
    fn every_controller_missing_means_not_installed() {
        let result = collect_controller_health(vec![
            Err(PluginError::PipelineNotFound("a".into())),
            Err(PluginError::PipelineNotFound("b".into())),
        ]);

        assert!(matches!(result, Err(PluginError::PipelineNotFound(_))));
    }

    #[test]
    fn other_errors_fail_the_whole_check() {
        let result = collect_controller_health(vec![
            Err(PluginError::PermissionDenied("forbidden".into())),
            Err(PluginError::ServiceUnavailable {
                message: "503".into(),
                retry_after: None,
            }),
        ]);

        assert!(matches!(
            result,
            Err(PluginError::ServiceUnavailable { .. })
        ));
    }

    #[test]
    fn health_levels_order_by_severity() {
        assert!(HealthLevel::Operational < HealthLevel::Maintenance);
        assert!(HealthLevel::Maintenance < HealthLevel::Degraded);
        assert!(HealthLevel::Degraded < HealthLevel::PartialOutage);
        assert!(HealthLevel::PartialOutage < HealthLevel::MajorOutage);
    }

    #[test]
    fn custom_source_is_absent_when_unset_or_blank() {
        assert_eq!(custom_status_source(&HashMap::new()).unwrap(), None);
        assert_eq!(source_for("   ").unwrap(), None);
    }

    #[test]
    fn custom_source_accepts_https_and_normalizes_trailing_slash() {
        let source = source_for("https://status.example.com/team")
            .unwrap()
            .unwrap();
        assert_eq!(source.name, "status.example.com");
        assert_eq!(source.page_url.as_str(), "https://status.example.com/team/");
        assert_eq!(
            source.feed_url.as_str(),
            "https://status.example.com/team/api/v2/summary.json"
        );
        assert_eq!(source.format, FeedFormat::Statuspage);
        assert_eq!(source.components, ComponentFilter::All);

        let same = source_for("https://status.example.com/team/")
            .unwrap()
            .unwrap();
        assert_eq!(same.feed_url, source.feed_url);
    }

    #[test]
    fn custom_source_accepts_literal_loopback_http() {
        let v4 = source_for("http://127.0.0.1:8765").unwrap().unwrap();
        assert_eq!(
            v4.feed_url.as_str(),
            "http://127.0.0.1:8765/api/v2/summary.json"
        );
        assert!(source_for("http://[::1]:8765/").unwrap().is_some());
    }

    #[test]
    fn custom_source_rejects_unsafe_urls() {
        for url in [
            "https://user:pass@status.example.com",
            "https://user@status.example.com",
            "https://status.example.com/?x=1",
            "https://status.example.com/#frag",
            "javascript:alert(1)",
            "http://10.0.0.1",
            "http://169.254.169.254",
            "http://localhost:8765",
            "http://status.example.com",
            "https://",
            "file:///etc/passwd",
            "not a url",
        ] {
            assert!(
                matches!(source_for(url), Err(PluginError::InvalidConfig(_))),
                "{url} should be rejected"
            );
        }
    }
}