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}
67
68pub fn status_page_field() -> ConfigField {
69    ConfigField {
70        key: STATUS_PAGE_URL_KEY.to_string(),
71        label: "Status page URL (optional)".to_string(),
72        description: Some(
73            "Statuspage address for this instance, for example https://status.example.com. \
74             Pipedash reads its /api/v2/summary.json."
75                .to_string(),
76        ),
77        field_type: ConfigFieldType::Text,
78        required: false,
79        default_value: None,
80        options: None,
81        validation_regex: Some(
82            r"^(https://[^\s/?#@]+|http://(127\.0\.0\.1|\[::1\])(:[0-9]+)?)(/[^\s?#]*)?$"
83                .to_string(),
84        ),
85        validation_message: Some(
86            "Must be an https URL without credentials, query or fragment".to_string(),
87        ),
88        section: ConfigSection::Settings,
89    }
90}
91
92pub fn custom_status_source(
93    config: &HashMap<String, String>,
94) -> PluginResult<Option<StatusSource>> {
95    let Some(raw) = config
96        .get(STATUS_PAGE_URL_KEY)
97        .map(|v| v.trim())
98        .filter(|v| !v.is_empty())
99    else {
100        return Ok(None);
101    };
102
103    let invalid = || {
104        PluginError::InvalidConfig(
105            "status_page_url must be an https URL with a host and no credentials, query or \
106             fragment"
107                .to_string(),
108        )
109    };
110
111    let mut page_url = Url::parse(raw).map_err(|_| invalid())?;
112    let allowed_scheme = match page_url.scheme() {
113        "https" => true,
114        "http" => is_literal_loopback(&page_url),
115        _ => false,
116    };
117    if !allowed_scheme
118        || page_url.host_str().is_none_or(str::is_empty)
119        || !page_url.username().is_empty()
120        || page_url.password().is_some()
121        || page_url.query().is_some()
122        || page_url.fragment().is_some()
123    {
124        return Err(invalid());
125    }
126
127    if !page_url.path().ends_with('/') {
128        let path = format!("{}/", page_url.path());
129        page_url.set_path(&path);
130    }
131
132    let feed_url = page_url
133        .join("api/v2/summary.json")
134        .map_err(|_| invalid())?;
135    let name = page_url.host_str().unwrap_or_default().to_string();
136
137    Ok(Some(StatusSource {
138        name,
139        page_url,
140        feed_url,
141        format: FeedFormat::Statuspage,
142        components: ComponentFilter::All,
143    }))
144}
145
146fn is_literal_loopback(url: &Url) -> bool {
147    matches!(url.host_str(), Some("127.0.0.1" | "[::1]"))
148}
149
150pub fn collect_controller_health(
151    checks: Vec<PluginResult<Option<ComponentHealth>>>,
152) -> PluginResult<Vec<ComponentHealth>> {
153    let mut components = Vec::new();
154    let mut denied = None;
155    let mut missing = None;
156    let total = checks.len();
157    let mut missing_count = 0;
158
159    for check in checks {
160        match check {
161            Ok(component) => components.extend(component),
162            Err(PluginError::PermissionDenied(message)) => {
163                denied.get_or_insert(message);
164            }
165            Err(PluginError::PipelineNotFound(message)) => {
166                missing_count += 1;
167                missing.get_or_insert(message);
168            }
169            Err(error) => return Err(error),
170        }
171    }
172
173    if let Some(message) = denied {
174        return Err(PluginError::PermissionDenied(message));
175    }
176    match missing {
177        Some(message) if missing_count == total => Err(PluginError::PipelineNotFound(message)),
178        _ => Ok(components),
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn source_for(url: &str) -> PluginResult<Option<StatusSource>> {
187        let mut config = HashMap::new();
188        config.insert(STATUS_PAGE_URL_KEY.to_string(), url.to_string());
189        custom_status_source(&config)
190    }
191
192    fn controller(name: &str) -> ComponentHealth {
193        ComponentHealth {
194            name: name.to_string(),
195            level: HealthLevel::MajorOutage,
196            detail: Some("scaled to zero".to_string()),
197        }
198    }
199
200    #[test]
201    fn controller_health_leaves_out_healthy_and_single_missing_controllers() {
202        let result = collect_controller_health(vec![
203            Ok(None),
204            Ok(Some(controller("kustomize-controller"))),
205            Err(PluginError::PipelineNotFound("helm-controller".into())),
206        ]);
207
208        assert_eq!(result.unwrap(), vec![controller("kustomize-controller")]);
209    }
210
211    #[test]
212    fn any_denied_controller_makes_the_check_denied() {
213        let result = collect_controller_health(vec![
214            Ok(Some(controller("kustomize-controller"))),
215            Err(PluginError::PermissionDenied("forbidden".into())),
216        ]);
217
218        assert!(matches!(result, Err(PluginError::PermissionDenied(_))));
219    }
220
221    #[test]
222    fn every_controller_missing_means_not_installed() {
223        let result = collect_controller_health(vec![
224            Err(PluginError::PipelineNotFound("a".into())),
225            Err(PluginError::PipelineNotFound("b".into())),
226        ]);
227
228        assert!(matches!(result, Err(PluginError::PipelineNotFound(_))));
229    }
230
231    #[test]
232    fn other_errors_fail_the_whole_check() {
233        let result = collect_controller_health(vec![
234            Err(PluginError::PermissionDenied("forbidden".into())),
235            Err(PluginError::ServiceUnavailable {
236                message: "503".into(),
237                retry_after: None,
238            }),
239        ]);
240
241        assert!(matches!(
242            result,
243            Err(PluginError::ServiceUnavailable { .. })
244        ));
245    }
246
247    #[test]
248    fn health_levels_order_by_severity() {
249        assert!(HealthLevel::Operational < HealthLevel::Maintenance);
250        assert!(HealthLevel::Maintenance < HealthLevel::Degraded);
251        assert!(HealthLevel::Degraded < HealthLevel::PartialOutage);
252        assert!(HealthLevel::PartialOutage < HealthLevel::MajorOutage);
253    }
254
255    #[test]
256    fn custom_source_is_absent_when_unset_or_blank() {
257        assert_eq!(custom_status_source(&HashMap::new()).unwrap(), None);
258        assert_eq!(source_for("   ").unwrap(), None);
259    }
260
261    #[test]
262    fn custom_source_accepts_https_and_normalizes_trailing_slash() {
263        let source = source_for("https://status.example.com/team")
264            .unwrap()
265            .unwrap();
266        assert_eq!(source.name, "status.example.com");
267        assert_eq!(source.page_url.as_str(), "https://status.example.com/team/");
268        assert_eq!(
269            source.feed_url.as_str(),
270            "https://status.example.com/team/api/v2/summary.json"
271        );
272        assert_eq!(source.format, FeedFormat::Statuspage);
273        assert_eq!(source.components, ComponentFilter::All);
274
275        let same = source_for("https://status.example.com/team/")
276            .unwrap()
277            .unwrap();
278        assert_eq!(same.feed_url, source.feed_url);
279    }
280
281    #[test]
282    fn custom_source_accepts_literal_loopback_http() {
283        let v4 = source_for("http://127.0.0.1:8765").unwrap().unwrap();
284        assert_eq!(
285            v4.feed_url.as_str(),
286            "http://127.0.0.1:8765/api/v2/summary.json"
287        );
288        assert!(source_for("http://[::1]:8765/").unwrap().is_some());
289    }
290
291    #[test]
292    fn custom_source_rejects_unsafe_urls() {
293        for url in [
294            "https://user:pass@status.example.com",
295            "https://user@status.example.com",
296            "https://status.example.com/?x=1",
297            "https://status.example.com/#frag",
298            "javascript:alert(1)",
299            "http://10.0.0.1",
300            "http://169.254.169.254",
301            "http://localhost:8765",
302            "http://status.example.com",
303            "https://",
304            "file:///etc/passwd",
305            "not a url",
306        ] {
307            assert!(
308                matches!(source_for(url), Err(PluginError::InvalidConfig(_))),
309                "{url} should be rejected"
310            );
311        }
312    }
313}