1use super::{AUTO_PREFIX, Kind, MAX_PER_ORG, Monitor, STACK_PREFIX, Settings};
16use crate::app::Apps;
17use crate::org::OrgId;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Candidate {
22 pub target: Target,
23 pub declared: bool,
25 pub served: bool,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Target {
32 App(String),
33 Service { stack: String, service: String },
34}
35
36impl Target {
37 fn owns(&self, m: &Monitor) -> bool {
39 match self {
40 Target::App(a) => m.kind == Kind::App && m.app.as_deref() == Some(a),
41 Target::Service { stack, service } => {
42 m.kind == Kind::Service
43 && m.stack.as_deref() == Some(stack)
44 && m.service.as_deref() == Some(service)
45 }
46 }
47 }
48
49 fn excluded(&self, s: &Settings) -> bool {
50 match self {
51 Target::App(a) => s.exclude_apps.contains(a),
52 Target::Service { stack, service } => {
53 s.exclude_services.contains(&exclusion(stack, service))
54 }
55 }
56 }
57}
58
59pub fn exclusion(stack: &str, service: &str) -> String {
61 format!("{stack}/{service}")
62}
63
64pub fn service_monitor_name(stack: &str, service: &str) -> String {
68 let n = format!(
69 "{STACK_PREFIX}{stack}-{}",
70 crate::compose::sanitize_name(service)
71 );
72 if super::validate_name(&n).is_ok() {
73 n
74 } else {
75 hashed_name(stack, service)
76 }
77}
78
79pub fn hashed_name(stack: &str, service: &str) -> String {
82 let h = super::service::fnv(&exclusion(stack, service)) & 0xff_ffff;
83 let tail = format!("-{h:06x}");
84 let base = format!(
85 "{STACK_PREFIX}{stack}-{}",
86 crate::compose::sanitize_name(service)
87 );
88 let head: String = base.chars().take(63 - tail.len()).collect();
89 format!("{}{tail}", head.trim_end_matches('-'))
90}
91
92fn serving(d: &crate::ingress::DomainStatus) -> bool {
94 d.url.is_some() && matches!(d.state.as_str(), "serving" | "no-replicas")
95}
96
97pub fn apps(apps: &Apps, org: &OrgId) -> crate::error::Result<Vec<Candidate>> {
99 let ctl = apps.controller();
100 Ok(apps
101 .list(org)?
102 .into_iter()
103 .map(|a| {
104 let served = a.spec.stack().ok().is_some_and(|stack| {
105 ctl.status(&crate::stack::qualified(org, &stack))
106 .ok()
107 .and_then(|s| s.services.into_iter().find(|x| x.service == a.spec.name))
108 .is_some_and(|s| s.domains.iter().any(|d| d.url.is_some()))
109 });
110 Candidate {
111 declared: !a.spec.domains.is_empty(),
112 served,
113 target: Target::App(a.spec.name),
114 }
115 })
116 .collect())
117}
118
119pub fn stack_services(apps: &Apps, org: &OrgId) -> Vec<Candidate> {
122 let ctl = apps.controller();
123 let mut out = Vec::new();
124 for d in ctl.definitions().into_iter().filter(|d| d.org == *org) {
125 if apps.managed_by(org, &d.name).is_some() || Apps::app_rendered(&d) {
126 continue;
127 }
128 let status = ctl.status(&d.qualified()).ok();
129 for (svc, spec) in &d.file.services {
130 if spec.labels.contains_key(crate::app::LABEL_APP) {
131 continue;
132 }
133 let served = status
134 .as_ref()
135 .and_then(|s| s.services.iter().find(|x| x.service == *svc))
136 .is_some_and(|s| s.domains.iter().any(serving));
137 out.push(Candidate {
138 target: Target::Service {
139 stack: d.name.clone(),
140 service: svc.clone(),
141 },
142 declared: !spec.domains.is_empty(),
143 served,
144 });
145 }
146 }
147 out
148}
149
150pub fn reconcile(
155 all: &mut Vec<Monitor>,
156 settings: &Settings,
157 cands: &[Candidate],
158 now: u64,
159) -> (Vec<String>, Vec<String>) {
160 let wanted =
161 |c: &Candidate| settings.auto_monitors && c.declared && !c.target.excluded(settings);
162 let mut gone = Vec::new();
163 all.retain(|m| {
164 let keep = !m.auto || cands.iter().any(|c| c.target.owns(m) && wanted(c));
165 if !keep {
166 gone.push(m.name.clone());
167 }
168 keep
169 });
170 let mut added = Vec::new();
171 for c in cands.iter().filter(|c| wanted(c) && c.served) {
172 if all.len() >= MAX_PER_ORG || all.iter().any(|m| m.auto && c.target.owns(m)) {
173 continue;
174 }
175 let m = match &c.target {
176 Target::App(a) => {
177 let name = format!("{AUTO_PREFIX}{a}");
178 if all.iter().any(|m| m.name == name) {
180 continue;
181 }
182 let mut m = Monitor::new(&name, Kind::App);
183 m.app = Some(a.clone());
184 m
185 }
186 Target::Service { stack, service } => {
187 let taken = |n: &str| all.iter().any(|m| m.name == n);
188 let plain = service_monitor_name(stack, service);
189 let name = if !taken(&plain) {
190 plain
191 } else {
192 let h = hashed_name(stack, service);
193 if taken(&h) {
194 continue;
195 }
196 h
197 };
198 let mut m = Monitor::new(&name, Kind::Service);
199 (m.stack, m.service) = (Some(stack.clone()), Some(service.clone()));
200 m
201 }
202 };
203 let mut m = m;
204 m.auto = true;
205 (m.created_at, m.updated_at) = (now, now);
206 added.push(m.name.clone());
207 all.push(m);
208 }
209 (gone, added)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn svc(stack: &str, service: &str, declared: bool, served: bool) -> Candidate {
217 Candidate {
218 target: Target::Service {
219 stack: stack.into(),
220 service: service.into(),
221 },
222 declared,
223 served,
224 }
225 }
226
227 fn app(name: &str, declared: bool, served: bool) -> Candidate {
228 Candidate {
229 target: Target::App(name.into()),
230 declared,
231 served,
232 }
233 }
234
235 fn names(all: &[Monitor]) -> Vec<&str> {
236 all.iter().map(|m| m.name.as_str()).collect()
237 }
238
239 #[test]
240 fn names_fit_and_never_look_like_an_apps() {
241 assert_eq!(service_monitor_name("wiki", "web"), "stack-wiki-web");
242 assert_eq!(service_monitor_name("wiki", "Web_UI"), "stack-wiki-web-ui");
243 let long = service_monitor_name(
244 "a-rather-long-stack-name-here",
245 "an-even-longer-service-name-than-that",
246 );
247 assert_eq!(long.len(), 63, "{long}");
248 assert!(long.starts_with("stack-a-rather-long-stack-name-here-an-even"));
249 assert_eq!(
250 long,
251 service_monitor_name(
252 "a-rather-long-stack-name-here",
253 "an-even-longer-service-name-than-that"
254 ),
255 "deterministic"
256 );
257 super::super::validate_name(&long).unwrap();
258 assert_ne!(hashed_name("a-b", "c"), hashed_name("a", "b-c"));
259 for n in [hashed_name("a-b", "c"), service_monitor_name("x", "1")] {
260 super::super::validate_name(&n).unwrap();
261 assert!(n.starts_with(STACK_PREFIX) && !n.starts_with(AUTO_PREFIX));
262 }
263 }
264
265 #[test]
266 fn served_services_get_one_and_lose_it_with_their_domain() {
267 let s = Settings::default();
268 let mut all = Vec::new();
269 let c = [
270 svc("wiki", "web", true, true),
271 svc("wiki", "redis", false, false),
272 svc("blog", "ghost", true, false),
274 app("shop", true, true),
275 ];
276 let (gone, added) = reconcile(&mut all, &s, &c, 7);
277 assert!(gone.is_empty());
278 assert_eq!(added, ["stack-wiki-web", "app-shop"]);
279 let m = &all[0];
280 assert_eq!(m.kind, Kind::Service);
281 assert_eq!(
282 (m.stack.as_deref(), m.service.as_deref()),
283 (Some("wiki"), Some("web"))
284 );
285 assert!(m.auto && m.created_at == 7);
286 m.validate().unwrap();
287 assert_eq!(reconcile(&mut all, &s, &c, 8), (vec![], vec![]));
289 let c2 = [svc("wiki", "web", true, false), app("shop", true, true)];
291 assert_eq!(reconcile(&mut all, &s, &c2, 9), (vec![], vec![]));
292 let c3 = [svc("wiki", "web", false, false), app("shop", true, true)];
294 let (gone, _) = reconcile(&mut all, &s, &c3, 10);
295 assert_eq!(gone, ["stack-wiki-web"]);
296 assert_eq!(names(&all), ["app-shop"]);
297 }
298
299 #[test]
300 fn exclusions_and_the_org_switch() {
301 let mut s = Settings::default();
302 let c = [svc("wiki", "web", true, true), app("shop", true, true)];
303 let mut all = Vec::new();
304 reconcile(&mut all, &s, &c, 1);
305 s.exclude_services = vec![exclusion("wiki", "web")];
306 let (gone, added) = reconcile(&mut all, &s, &c, 2);
307 assert_eq!((gone, added), (vec!["stack-wiki-web".to_string()], vec![]));
308 s.exclude_services.clear();
309 s.auto_monitors = false;
310 let (gone, _) = reconcile(&mut all, &s, &c, 3);
311 assert_eq!(gone, ["app-shop"]);
312 assert!(all.is_empty());
313 }
314
315 #[test]
316 fn names_taken_by_others_are_respected() {
317 let s = Settings::default();
318 let mut mine = Monitor::new("stack-a-b-c", Kind::Tcp);
320 (mine.host, mine.port) = (Some("x".into()), Some(1));
321 let mut all = vec![mine];
322 let c = [svc("a-b", "c", true, true), svc("a", "b-c", true, true)];
324 let (_, added) = reconcile(&mut all, &s, &c, 1);
325 assert_eq!(added, [hashed_name("a-b", "c"), hashed_name("a", "b-c")]);
326 assert_eq!(all.len(), 3);
327 assert_eq!(reconcile(&mut all, &s, &c, 2), (vec![], vec![]));
329 let mut all = Vec::new();
331 let c = [app("wiki-web", true, true), svc("wiki", "web", true, true)];
332 let (_, added) = reconcile(&mut all, &s, &c, 1);
333 assert_eq!(added, ["app-wiki-web", "stack-wiki-web"]);
334 }
335}