Skip to main content

pipedash_plugin_api/
health.rs

1use std::collections::HashMap;
2
3use reqwest::Url;
4use serde::{
5    Deserialize,
6    Serialize,
7};
8
9use crate::error::{
10    PluginError,
11    PluginResult,
12};
13use crate::schema::{
14    ConfigField,
15    ConfigFieldType,
16    ConfigSection,
17};
18
19pub const STATUS_PAGE_URL_KEY: &str = "status_page_url";
20pub const CONTROLLER_HEALTH_FEATURE: &str = "controller_health";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum HealthLevel {
26    Operational,
27    Maintenance,
28    Degraded,
29    PartialOutage,
30    MajorOutage,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ComponentHealth {
35    pub name: String,
36    pub level: HealthLevel,
37    pub detail: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41pub enum ComponentFilter {
42    All,
43    Ids(Vec<String>),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum FeedFormat {
49    Statuspage,
50    StatusIo,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct StatusSource {
55    pub name: String,
56    pub page_url: Url,
57    pub feed_url: Url,
58    pub format: FeedFormat,
59    pub components: ComponentFilter,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct ClientOptions {
64    pub origin: Option<Url>,
65    pub accept_invalid_certs: bool,
66    pub same_host_redirects: bool,
67}
68
69pub fn status_page_field() -> ConfigField {
70    ConfigField {
71        key: STATUS_PAGE_URL_KEY.to_string(),
72        label: "Status page URL (optional)".to_string(),
73        description: Some(
74            "Statuspage address for this instance, for example https://status.example.com. \
75             Pipedash reads its /api/v2/summary.json."
76                .to_string(),
77        ),
78        field_type: ConfigFieldType::Text,
79        required: false,
80        default_value: None,
81        options: None,
82        validation_regex: Some(
83            r"^(https://[^\s/?#@]+|http://(127\.0\.0\.1|\[::1\])(:[0-9]+)?)(/[^\s?#]*)?$"
84                .to_string(),
85        ),
86        validation_message: Some(
87            "Must be an https URL without credentials, query or fragment".to_string(),
88        ),
89        section: ConfigSection::Settings,
90    }
91}
92
93pub fn custom_status_source(
94    config: &HashMap<String, String>,
95) -> PluginResult<Option<StatusSource>> {
96    let Some(raw) = config
97        .get(STATUS_PAGE_URL_KEY)
98        .map(|v| v.trim())
99        .filter(|v| !v.is_empty())
100    else {
101        return Ok(None);
102    };
103
104    let invalid = || {
105        PluginError::InvalidConfig(
106            "status_page_url must be an https URL with a host and no credentials, query or \
107             fragment"
108                .to_string(),
109        )
110    };
111
112    let mut page_url = Url::parse(raw).map_err(|_| invalid())?;
113    let allowed_scheme = match page_url.scheme() {
114        "https" => true,
115        "http" => is_literal_loopback(&page_url),
116        _ => false,
117    };
118    if !allowed_scheme
119        || page_url.host_str().is_none_or(str::is_empty)
120        || !page_url.username().is_empty()
121        || page_url.password().is_some()
122        || page_url.query().is_some()
123        || page_url.fragment().is_some()
124    {
125        return Err(invalid());
126    }
127
128    if !page_url.path().ends_with('/') {
129        let path = format!("{}/", page_url.path());
130        page_url.set_path(&path);
131    }
132
133    let feed_url = page_url
134        .join("api/v2/summary.json")
135        .map_err(|_| invalid())?;
136    let name = page_url.host_str().unwrap_or_default().to_string();
137
138    Ok(Some(StatusSource {
139        name,
140        page_url,
141        feed_url,
142        format: FeedFormat::Statuspage,
143        components: ComponentFilter::All,
144    }))
145}
146
147fn is_literal_loopback(url: &Url) -> bool {
148    matches!(url.host_str(), Some("127.0.0.1" | "[::1]"))
149}
150
151pub fn deployment_health(
152    name: &str, desired: Option<impl Into<i64>>, available: Option<impl Into<i64>>,
153    unavailable: bool,
154) -> Option<ComponentHealth> {
155    let (desired, available) = (desired.map(Into::into), available.map(Into::into));
156    let (level, detail) = match (desired, available) {
157        (Some(0), _) => (HealthLevel::MajorOutage, "scaled to zero".to_string()),
158        (_, Some(0)) => (HealthLevel::MajorOutage, "not running".to_string()),
159        (Some(desired), Some(available)) if available < desired => (
160            HealthLevel::Degraded,
161            format!("{available}/{desired} replicas available"),
162        ),
163        (Some(_), Some(_)) => return None,
164        _ if unavailable => (HealthLevel::MajorOutage, "not available".to_string()),
165        _ => return None,
166    };
167
168    Some(ComponentHealth {
169        name: name.to_string(),
170        level,
171        detail: Some(detail),
172    })
173}
174
175pub fn collect_controller_health(
176    checks: Vec<PluginResult<Option<ComponentHealth>>>,
177) -> PluginResult<Vec<ComponentHealth>> {
178    let mut components = Vec::new();
179    let mut denied = None;
180    let mut missing = None;
181    let total = checks.len();
182    let mut missing_count = 0;
183
184    for check in checks {
185        match check {
186            Ok(component) => components.extend(component),
187            Err(PluginError::PermissionDenied(message)) => {
188                denied.get_or_insert(message);
189            }
190            Err(PluginError::PipelineNotFound(message)) => {
191                missing_count += 1;
192                missing.get_or_insert(message);
193            }
194            Err(error) => return Err(error),
195        }
196    }
197
198    if let Some(message) = denied {
199        return Err(PluginError::PermissionDenied(message));
200    }
201    match missing {
202        Some(message) if missing_count == total => Err(PluginError::PipelineNotFound(message)),
203        _ => Ok(components),
204    }
205}
206
207#[cfg(test)]
208mod tests;