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}