Skip to main content

isb_apps/monitor/
auto.rs

1//! Monitors isb makes by itself: which apps and compose stack services get
2//! one of their own, what it is called, and how the org's list follows them.
3//!
4//! An app with a served domain gets `app-<name>`. A compose stack service
5//! (`stack_deploy`) with a served domain gets `stack-<stack>-<service>`,
6//! shortened with a hash when that is over 63 characters or already names
7//! another stack service's monitor. Stacks that apps render (a project
8//! environment's, a preview's) are left to the apps' monitors, and the
9//! ingress tunnel's stack serves nothing of its own.
10//!
11//! A monitor is made once its target serves a domain, and kept while the
12//! target exists and declares one, so a domain briefly in conflict or with
13//! no replica neither loses the history nor makes a new monitor.
14
15use super::{AUTO_PREFIX, Kind, MAX_PER_ORG, Monitor, STACK_PREFIX, Settings};
16use crate::app::Apps;
17use crate::org::OrgId;
18
19/// Something that may get a monitor of its own.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Candidate {
22    pub target: Target,
23    /// Its definition has domains: an existing monitor stays.
24    pub declared: bool,
25    /// The ingress serves one of them now: a missing monitor is made.
26    pub served: bool,
27}
28
29/// An app, or a compose stack's service.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Target {
32    App(String),
33    Service { stack: String, service: String },
34}
35
36impl Target {
37    /// Is `m` this target's own monitor?
38    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
59/// How a stack service is named in the org's exclusions.
60pub fn exclusion(stack: &str, service: &str) -> String {
61    format!("{stack}/{service}")
62}
63
64/// The monitor a compose stack service gets: `stack-<stack>-<service>`
65/// (the service as instance names spell it), or the hashed form when that
66/// does not fit.
67pub 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
79/// `stack-<stack>-<service>` cut to fit, with a hash of the pair: unique
80/// where the plain name is ambiguous (`a-b`/`c` and `a`/`b-c`).
81pub 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
92/// Does a served domain status count (the ingress routes it to replicas)?
93fn serving(d: &crate::ingress::DomainStatus) -> bool {
94    d.url.is_some() && matches!(d.state.as_str(), "serving" | "no-replicas")
95}
96
97/// The org's apps, as candidates.
98pub 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
119/// The org's compose stack services, as candidates: every service of a
120/// stack no app renders, but the ingress tunnel's.
121pub 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
150/// Bring the auto monitors in `all` in line with `cands`: drop those whose
151/// target is gone, declares no domain or is excluded (all of them when the
152/// org turned them off), make those missing for a served target. Returns
153/// the names removed and the names made; `now` is unix seconds.
154pub 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                // A monitor of that name the user made keeps it.
179                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            // Declared, not served yet (a conflict, refused, or starting).
273            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        // Again: nothing to do.
288        assert_eq!(reconcile(&mut all, &s, &c, 8), (vec![], vec![]));
289        // Not served for a while, still declared: kept.
290        let c2 = [svc("wiki", "web", true, false), app("shop", true, true)];
291        assert_eq!(reconcile(&mut all, &s, &c2, 9), (vec![], vec![]));
292        // The domain removed: gone. The stack removed: gone too.
293        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        // The user's own monitor of that name stays theirs.
319        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        // `a-b`/`c` and `a`/`b-c` would both be stack-a-b-c.
323        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        // Kept by what they follow, not by name.
328        assert_eq!(reconcile(&mut all, &s, &c, 2), (vec![], vec![]));
329        // An app's own monitor and a stack service's never collide.
330        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}