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}