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 deployment_health(
152 name: &str, desired: Option<impl Into<i64>>, available: Option<impl Into<i64>>,
153 unavailable: bool,
154) -> Option<ComponentHealth> {
155 let (desired, available) = (desired.map(Into::into), available.map(Into::into));
156 let (level, detail) = match (desired, available) {
157 (Some(0), _) => (HealthLevel::MajorOutage, "scaled to zero".to_string()),
158 (_, Some(0)) => (HealthLevel::MajorOutage, "not running".to_string()),
159 (Some(desired), Some(available)) if available < desired => (
160 HealthLevel::Degraded,
161 format!("{available}/{desired} replicas available"),
162 ),
163 (Some(_), Some(_)) => return None,
164 _ if unavailable => (HealthLevel::MajorOutage, "not available".to_string()),
165 _ => return None,
166 };
167
168 Some(ComponentHealth {
169 name: name.to_string(),
170 level,
171 detail: Some(detail),
172 })
173}
174
175pub fn collect_controller_health(
176 checks: Vec<PluginResult<Option<ComponentHealth>>>,
177) -> PluginResult<Vec<ComponentHealth>> {
178 let mut components = Vec::new();
179 let mut denied = None;
180 let mut missing = None;
181 let total = checks.len();
182 let mut missing_count = 0;
183
184 for check in checks {
185 match check {
186 Ok(component) => components.extend(component),
187 Err(PluginError::PermissionDenied(message)) => {
188 denied.get_or_insert(message);
189 }
190 Err(PluginError::PipelineNotFound(message)) => {
191 missing_count += 1;
192 missing.get_or_insert(message);
193 }
194 Err(error) => return Err(error),
195 }
196 }
197
198 if let Some(message) = denied {
199 return Err(PluginError::PermissionDenied(message));
200 }
201 match missing {
202 Some(message) if missing_count == total => Err(PluginError::PipelineNotFound(message)),
203 _ => Ok(components),
204 }
205}
206
207#[cfg(test)]
208mod tests;