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}