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