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 collect_controller_health(
152    checks: Vec<PluginResult<Option<ComponentHealth>>>,
153) -> PluginResult<Vec<ComponentHealth>> {
154    let mut components = Vec::new();
155    let mut denied = None;
156    let mut missing = None;
157    let total = checks.len();
158    let mut missing_count = 0;
159
160    for check in checks {
161        match check {
162            Ok(component) => components.extend(component),
163            Err(PluginError::PermissionDenied(message)) => {
164                denied.get_or_insert(message);
165            }
166            Err(PluginError::PipelineNotFound(message)) => {
167                missing_count += 1;
168                missing.get_or_insert(message);
169            }
170            Err(error) => return Err(error),
171        }
172    }
173
174    if let Some(message) = denied {
175        return Err(PluginError::PermissionDenied(message));
176    }
177    match missing {
178        Some(message) if missing_count == total => Err(PluginError::PipelineNotFound(message)),
179        _ => Ok(components),
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn source_for(url: &str) -> PluginResult<Option<StatusSource>> {
188        let mut config = HashMap::new();
189        config.insert(STATUS_PAGE_URL_KEY.to_string(), url.to_string());
190        custom_status_source(&config)
191    }
192
193    fn controller(name: &str) -> ComponentHealth {
194        ComponentHealth {
195            name: name.to_string(),
196            level: HealthLevel::MajorOutage,
197            detail: Some("scaled to zero".to_string()),
198        }
199    }
200
201    #[test]
202    fn controller_health_leaves_out_healthy_and_single_missing_controllers() {
203        let result = collect_controller_health(vec![
204            Ok(None),
205            Ok(Some(controller("kustomize-controller"))),
206            Err(PluginError::PipelineNotFound("helm-controller".into())),
207        ]);
208
209        assert_eq!(result.unwrap(), vec![controller("kustomize-controller")]);
210    }
211
212    #[test]
213    fn any_denied_controller_makes_the_check_denied() {
214        let result = collect_controller_health(vec![
215            Ok(Some(controller("kustomize-controller"))),
216            Err(PluginError::PermissionDenied("forbidden".into())),
217        ]);
218
219        assert!(matches!(result, Err(PluginError::PermissionDenied(_))));
220    }
221
222    #[test]
223    fn every_controller_missing_means_not_installed() {
224        let result = collect_controller_health(vec![
225            Err(PluginError::PipelineNotFound("a".into())),
226            Err(PluginError::PipelineNotFound("b".into())),
227        ]);
228
229        assert!(matches!(result, Err(PluginError::PipelineNotFound(_))));
230    }
231
232    #[test]
233    fn other_errors_fail_the_whole_check() {
234        let result = collect_controller_health(vec![
235            Err(PluginError::PermissionDenied("forbidden".into())),
236            Err(PluginError::ServiceUnavailable {
237                message: "503".into(),
238                retry_after: None,
239            }),
240        ]);
241
242        assert!(matches!(
243            result,
244            Err(PluginError::ServiceUnavailable { .. })
245        ));
246    }
247
248    #[test]
249    fn health_levels_order_by_severity() {
250        assert!(HealthLevel::Operational < HealthLevel::Maintenance);
251        assert!(HealthLevel::Maintenance < HealthLevel::Degraded);
252        assert!(HealthLevel::Degraded < HealthLevel::PartialOutage);
253        assert!(HealthLevel::PartialOutage < HealthLevel::MajorOutage);
254    }
255
256    #[test]
257    fn custom_source_is_absent_when_unset_or_blank() {
258        assert_eq!(custom_status_source(&HashMap::new()).unwrap(), None);
259        assert_eq!(source_for("   ").unwrap(), None);
260    }
261
262    #[test]
263    fn custom_source_accepts_https_and_normalizes_trailing_slash() {
264        let source = source_for("https://status.example.com/team")
265            .unwrap()
266            .unwrap();
267        assert_eq!(source.name, "status.example.com");
268        assert_eq!(source.page_url.as_str(), "https://status.example.com/team/");
269        assert_eq!(
270            source.feed_url.as_str(),
271            "https://status.example.com/team/api/v2/summary.json"
272        );
273        assert_eq!(source.format, FeedFormat::Statuspage);
274        assert_eq!(source.components, ComponentFilter::All);
275
276        let same = source_for("https://status.example.com/team/")
277            .unwrap()
278            .unwrap();
279        assert_eq!(same.feed_url, source.feed_url);
280    }
281
282    #[test]
283    fn custom_source_accepts_literal_loopback_http() {
284        let v4 = source_for("http://127.0.0.1:8765").unwrap().unwrap();
285        assert_eq!(
286            v4.feed_url.as_str(),
287            "http://127.0.0.1:8765/api/v2/summary.json"
288        );
289        assert!(source_for("http://[::1]:8765/").unwrap().is_some());
290    }
291
292    #[test]
293    fn custom_source_rejects_unsafe_urls() {
294        for url in [
295            "https://user:pass@status.example.com",
296            "https://user@status.example.com",
297            "https://status.example.com/?x=1",
298            "https://status.example.com/#frag",
299            "javascript:alert(1)",
300            "http://10.0.0.1",
301            "http://169.254.169.254",
302            "http://localhost:8765",
303            "http://status.example.com",
304            "https://",
305            "file:///etc/passwd",
306            "not a url",
307        ] {
308            assert!(
309                matches!(source_for(url), Err(PluginError::InvalidConfig(_))),
310                "{url} should be rejected"
311            );
312        }
313    }
314}