Skip to main content

isb_apps/app/
compose.rs

1//! Compose stacks in project environments.
2//!
3//! Every compose stack (`stack_deploy`) belongs to exactly one project
4//! environment, recorded on the project ([`super::ComposeRef`]). Ownership
5//! is metadata only: attaching or detaching a stack never renames,
6//! redeploys or rolls it. What it adds is a name: a service `redis` of
7//! stack `wiki` in `wiki/production` is also `redis.wiki-production`, as
8//! the environment's apps are named (see [`crate::discovery`]).
9//!
10//! A name in an environment has one holder. A new deploy that would take
11//! a name the environment already gives an app or another stack's service
12//! is refused; where two held one before ownership existed, an app wins,
13//! then the stack that joined first (then the stack name), and the loser
14//! keeps its own `<service>.<stack>` but gets no environment name.
15//!
16//! Stacks with no owner (deployed before ownership existed) are adopted
17//! when the daemon starts: into the project of the same name when there is
18//! one, else a new project named after the stack.
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::sync::Arc;
22
23use serde::Serialize;
24
25use super::{ComposeRef, LABEL_APP, Project};
26use crate::error::{Error, Result};
27use crate::org::OrgId;
28use crate::stack::StackDef;
29use crate::stack::controller::{DnsScope, DnsScopeFn};
30
31use super::deploy::Apps;
32
33/// Which project environment a compose stack belongs to.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct ComposeOwner {
36    pub project: String,
37    pub environment: String,
38    /// Unix seconds.
39    pub added_at: u64,
40}
41
42/// The org's compose stacks by name, as the project records say.
43pub(super) type Owners = BTreeMap<String, ComposeOwner>;
44
45/// A name in an environment that a compose service did not get.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47pub struct Conflict {
48    /// The service without the environment name.
49    pub service: String,
50    /// Its stack.
51    pub stack: String,
52    /// The stack whose service holds the name (`<project>-<env>` for an
53    /// app).
54    pub winner: String,
55}
56
57/// An environment's compose stacks and the names they contest.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct EnvCompose {
60    /// `(stack, services)`, by stack name.
61    pub stacks: Vec<(String, Vec<String>)>,
62    pub conflicts: Vec<Conflict>,
63}
64
65/// A service's name in DNS.
66fn label(service: &str) -> String {
67    crate::compose::sanitize_name(service)
68}
69
70/// The project a stack is adopted into when no project has its name:
71/// the stack's name, cut to leave room for `-production` in a stack name,
72/// and numbered (`-2`, `-3`, ...) from the second try on.
73fn project_candidate(stack: &str, n: u32) -> String {
74    let suffix = if n <= 1 {
75        String::new()
76    } else {
77        format!("-{n}")
78    };
79    // `<project>-production` is a stack name, at most 30 characters.
80    const ROOM: usize = 30 - "-production".len();
81    if stack.len() + suffix.len() <= ROOM {
82        return format!("{stack}{suffix}");
83    }
84    let cut: String = stack.chars().take(ROOM - suffix.len()).collect();
85    format!("{}{suffix}", cut.trim_end_matches('-'))
86}
87
88/// Who loses a contested name: `(stack, service) -> winner`. The app
89/// stack's services hold theirs first, then stacks in order of joining.
90fn contest(
91    app_stack: &str,
92    apps: &BTreeSet<String>,
93    members: &[(String, u64, Vec<String>)],
94) -> BTreeMap<(String, String), String> {
95    let mut holder: BTreeMap<String, String> = apps
96        .iter()
97        .map(|a| (label(a), app_stack.to_string()))
98        .collect();
99    let mut order: Vec<&(String, u64, Vec<String>)> = members.iter().collect();
100    order.sort_by(|a, b| (a.1, &a.0).cmp(&(b.1, &b.0)));
101    let mut lost = BTreeMap::new();
102    for (stack, _, services) in order {
103        for svc in services {
104            match holder.get(&label(svc)) {
105                Some(w) if w != stack => {
106                    lost.insert((stack.clone(), svc.clone()), w.clone());
107                }
108                Some(_) => {}
109                None => {
110                    holder.insert(label(svc), stack.clone());
111                }
112            }
113        }
114    }
115    lost
116}
117
118impl Apps {
119    // --- the index ---------------------------------------------------------
120
121    /// The org's compose owners, from memory: read from the project records
122    /// once, and again after any of them changed.
123    pub(super) fn owners(&self, org: &OrgId) -> Arc<Owners> {
124        let mut idx = self.inner.owners.lock().unwrap();
125        if let Some(o) = idx.get(org) {
126            return o.clone();
127        }
128        // Read under the lock: a write that lands meanwhile invalidates
129        // after this, so nothing stale is kept.
130        let mut o = Owners::new();
131        for p in self.projects_raw(org).unwrap_or_default() {
132            for r in p.compose {
133                o.insert(
134                    r.stack,
135                    ComposeOwner {
136                        project: p.name.clone(),
137                        environment: r.environment,
138                        added_at: r.added_at,
139                    },
140                );
141            }
142        }
143        let o = Arc::new(o);
144        idx.insert(org.clone(), o.clone());
145        o
146    }
147
148    /// A project record changed: the index is read again when next asked.
149    pub(super) fn invalidate_owners(&self, org: &OrgId) {
150        self.inner.owners.lock().unwrap().remove(org);
151    }
152
153    /// The org's deployed stacks, by name.
154    fn org_defs(&self, org: &OrgId) -> BTreeMap<String, Arc<StackDef>> {
155        self.inner
156            .ctl
157            .definitions()
158            .into_iter()
159            .filter(|d| d.org == *org)
160            .map(|d| (d.name.clone(), d))
161            .collect()
162    }
163
164    pub(super) fn stack_live(&self, org: &OrgId, stack: &str) -> bool {
165        self.inner
166            .ctl
167            .definition(&crate::stack::qualified(org, stack))
168            .is_ok()
169    }
170
171    /// A stack whose services were rendered from apps (an environment's or
172    /// a preview's), whatever its name.
173    pub(crate) fn app_rendered(def: &StackDef) -> bool {
174        def.file
175            .services
176            .values()
177            .any(|s| s.labels.contains_key(LABEL_APP))
178    }
179
180    /// A deployed stack that is not an app stack: a compose stack.
181    pub(super) fn compose_stack_exists(&self, org: &OrgId, stack: &str) -> bool {
182        self.inner
183            .ctl
184            .definition(&crate::stack::qualified(org, stack))
185            .is_ok_and(|d| !Self::app_rendered(&d))
186    }
187
188    // --- ownership ---------------------------------------------------------
189
190    /// The project environment a deployed compose stack belongs to.
191    pub fn compose_owner(&self, org: &OrgId, stack: &str) -> Option<ComposeOwner> {
192        let o = self.owners(org).get(stack).cloned()?;
193        self.stack_live(org, stack).then_some(o)
194    }
195
196    /// What owns a stack besides its file: the apps of a project
197    /// environment (`<project>-<env>`, and a pull request's
198    /// `<project>-<env>-pr-<n>`), or the org's ingress tunnel.
199    pub fn managed_by(&self, org: &OrgId, stack: &str) -> Option<&'static str> {
200        if stack == crate::ingress::cloudflare::TUNNEL_STACK {
201            return Some("ingress");
202        }
203        let projects = self.projects_raw(org).ok()?;
204        projects
205            .iter()
206            .flat_map(|p| {
207                p.environments
208                    .iter()
209                    .map(move |e| format!("{}-{e}", p.name))
210            })
211            .any(|s| stack == s || stack.strip_prefix(&format!("{s}-pr-")).is_some())
212            .then_some("apps")
213    }
214
215    /// Where a stack with no owner goes: the project of its name (its
216    /// `production` environment, else its first), or a new project named
217    /// after it with a `production` environment. `(project, env, create)`.
218    pub fn default_owner(&self, org: &OrgId, stack: &str) -> Result<(String, String, bool)> {
219        let projects = self.projects_raw(org)?;
220        if let Some(p) = projects.iter().find(|p| p.name == stack) {
221            let env = if p
222                .environments
223                .iter()
224                .any(|e| e == super::DEFAULT_ENVIRONMENT)
225            {
226                Some(super::DEFAULT_ENVIRONMENT.to_string())
227            } else {
228                p.environments.first().cloned()
229            };
230            if let Some(e) = env {
231                return Ok((p.name.clone(), e, false));
232            }
233        }
234        for n in 1..1000 {
235            let cand = project_candidate(stack, n);
236            if super::validate_part("project", &cand).is_err() {
237                return Err(Error::invalid(format!(
238                    "stack {stack}: no project name can be made from it; pass a project"
239                )));
240            }
241            let env_stack = format!("{cand}-{}", super::DEFAULT_ENVIRONMENT);
242            let taken = projects.iter().any(|p| p.name == cand)
243                || env_stack == stack
244                || self.compose_stack_exists(org, &env_stack);
245            if !taken {
246                return Ok((cand, super::DEFAULT_ENVIRONMENT.into(), true));
247            }
248        }
249        Err(Error::invalid(format!(
250            "stack {stack}: every project name made from it is taken; pass a project"
251        )))
252    }
253
254    /// Make `stack` belong to `project`/`env`; with `create_project`, a
255    /// project that does not exist is made, with that one environment, in
256    /// the same write. A stack keeps the owner it has: attaching it
257    /// elsewhere is refused. Nothing is deployed.
258    pub fn compose_attach(
259        &self,
260        org: &OrgId,
261        project: &str,
262        env: &str,
263        stack: &str,
264        create_project: bool,
265    ) -> Result<ComposeOwner> {
266        let o = {
267            let _g = self.inner.edit.lock().unwrap();
268            self.attach_locked(org, project, env, stack, create_project)?
269        };
270        self.inner.ctl.republish_dns(org);
271        Ok(o)
272    }
273
274    fn attach_locked(
275        &self,
276        org: &OrgId,
277        project: &str,
278        env: &str,
279        stack: &str,
280        create_project: bool,
281    ) -> Result<ComposeOwner> {
282        crate::stack::validate_stack_name(stack)?;
283        let projects = self.projects_raw(org)?;
284        for p in &projects {
285            for e in &p.environments {
286                if format!("{}-{e}", p.name) == stack {
287                    return Err(Error::invalid(format!(
288                        "stack {stack} is where project {}'s environment {e} runs its apps; pick another name",
289                        p.name
290                    )));
291                }
292            }
293        }
294        if format!("{project}-{env}") == stack {
295            return Err(Error::invalid(format!(
296                "stack {stack} would be where project {project}'s environment {env} runs its apps; pick another name"
297            )));
298        }
299        for p in &projects {
300            if let Some(r) = p.compose.iter().find(|r| r.stack == stack) {
301                if p.name == project && r.environment == env {
302                    return Ok(ComposeOwner {
303                        project: p.name.clone(),
304                        environment: r.environment.clone(),
305                        added_at: r.added_at,
306                    });
307                }
308                // A record of a stack that is gone is no claim.
309                if self.stack_live(org, stack) {
310                    return Err(Error::invalid(format!(
311                        "stack {stack} belongs to {}/{}; a stack stays where it is",
312                        p.name, r.environment
313                    )));
314                }
315            }
316        }
317        let mut target = match projects.iter().find(|p| p.name == project) {
318            Some(p) => {
319                if !p.environments.iter().any(|e| e == env) {
320                    return Err(Error::NotFound(format!(
321                        "environment {env} in project {project} (it has {})",
322                        p.environments.join(", ")
323                    )));
324                }
325                p.clone()
326            }
327            None if create_project => {
328                super::validate_part("project", project)?;
329                super::validate_part("environment", env)?;
330                let env_stack = super::stack_name(project, env)?;
331                if self.compose_stack_exists(org, &env_stack) {
332                    return Err(Error::invalid(format!(
333                        "project {project}: its environment {env} would run as stack {env_stack}, which is a compose stack; pick another project name"
334                    )));
335                }
336                Project {
337                    name: project.into(),
338                    description: String::new(),
339                    environments: vec![env.into()],
340                    created_at: crate::stack::now_secs(),
341                    compose: Vec::new(),
342                }
343            }
344            None => return Err(Error::NotFound(format!("project {project} in org {org}"))),
345        };
346        let added_at = crate::stack::now_secs();
347        target.compose.retain(|r| r.stack != stack);
348        target.compose.push(ComposeRef {
349            stack: stack.into(),
350            environment: env.into(),
351            added_at,
352        });
353        self.save_project(org, &target)?;
354        // Stale records of it elsewhere go.
355        for mut p in projects {
356            if p.name != project && p.compose.iter().any(|r| r.stack == stack) {
357                p.compose.retain(|r| r.stack != stack);
358                self.save_project(org, &p)?;
359            }
360        }
361        Ok(ComposeOwner {
362            project: project.into(),
363            environment: env.into(),
364            added_at,
365        })
366    }
367
368    /// Forget which environment `stack` belongs to (it was removed).
369    /// Nothing to do for a stack with no owner.
370    pub fn compose_detach(&self, org: &OrgId, stack: &str) -> Result<()> {
371        let changed = {
372            let _g = self.inner.edit.lock().unwrap();
373            let mut changed = false;
374            for mut p in self.projects_raw(org)? {
375                if p.compose.iter().any(|r| r.stack == stack) {
376                    p.compose.retain(|r| r.stack != stack);
377                    self.save_project(org, &p)?;
378                    changed = true;
379                }
380            }
381            changed
382        };
383        if changed {
384            self.inner.ctl.republish_dns(org);
385        }
386        Ok(())
387    }
388
389    /// Give every stack in `stacks` with no owner one ([`Apps::default_owner`]),
390    /// creating its project when needed. Skipped: owned stacks, the ingress
391    /// tunnel, and stacks the apps run (environments and previews). Nothing
392    /// is deployed; running it again changes nothing. One line per stack
393    /// adopted, or why it was not.
394    pub fn adopt_compose_stacks(&self, stacks: &[(OrgId, String)]) -> Vec<Result<String>> {
395        let mut out = Vec::new();
396        let mut touched = BTreeSet::new();
397        for (org, stack) in stacks {
398            let r = {
399                let _g = self.inner.edit.lock().unwrap();
400                let skip = self.managed_by(org, stack).is_some()
401                    || self.owners(org).contains_key(stack)
402                    || self
403                        .inner
404                        .ctl
405                        .definition(&crate::stack::qualified(org, stack))
406                        .is_ok_and(|d| Self::app_rendered(&d));
407                if skip {
408                    continue;
409                }
410                self.default_owner(org, stack).and_then(|(p, e, create)| {
411                    self.attach_locked(org, &p, &e, stack, create)?;
412                    Ok(format!(
413                        "org {org}: stack {stack} belongs to {p}/{e}{}",
414                        if create { " (a new project)" } else { "" }
415                    ))
416                })
417            };
418            touched.insert(org.clone());
419            out.push(r.map_err(|e| {
420                Error::invalid(format!("org {org}: stack {stack} has no project: {e}"))
421            }));
422        }
423        for org in touched {
424            self.inner.ctl.republish_dns(&org);
425        }
426        out
427    }
428
429    // --- names in an environment --------------------------------------------
430
431    /// An environment's compose stacks that are deployed, with their join
432    /// times and services.
433    fn members(
434        &self,
435        owners: &Owners,
436        defs: &BTreeMap<String, Arc<StackDef>>,
437        project: &str,
438        env: &str,
439    ) -> Vec<(String, u64, Vec<String>)> {
440        owners
441            .iter()
442            .filter(|(_, o)| o.project == project && o.environment == env)
443            .filter_map(|(s, o)| {
444                let d = defs.get(s)?;
445                Some((
446                    s.clone(),
447                    o.added_at,
448                    d.file.services.keys().cloned().collect(),
449                ))
450            })
451            .collect()
452    }
453
454    /// The names the environment's apps hold: its stack's services.
455    fn app_names(defs: &BTreeMap<String, Arc<StackDef>>, app_stack: &str) -> BTreeSet<String> {
456        defs.get(app_stack)
457            .map(|d| d.file.services.keys().cloned().collect())
458            .unwrap_or_default()
459    }
460
461    /// The environment a stack's services are also named in, and which of
462    /// them are: what the controller's hosts files follow.
463    pub fn dns_scope(&self, org: &OrgId, stack: &str) -> Option<DnsScope> {
464        let owners = self.owners(org);
465        let o = owners.get(stack)?;
466        let name = format!("{}-{}", o.project, o.environment);
467        if name == stack {
468            return None;
469        }
470        let defs = self.org_defs(org);
471        let mine = defs.get(stack)?;
472        let members = self.members(&owners, &defs, &o.project, &o.environment);
473        let lost = contest(&name, &Self::app_names(&defs, &name), &members);
474        let alias = mine
475            .file
476            .services
477            .keys()
478            .filter(|s| !lost.contains_key(&(stack.to_string(), (*s).clone())))
479            .cloned()
480            .collect();
481        Some(DnsScope { name, alias })
482    }
483
484    /// [`Apps::dns_scope`] for [`crate::stack::Controller::set_dns_scope`].
485    /// It holds the apps weakly: the controller outliving them answers none.
486    pub fn scope_fn(&self) -> DnsScopeFn {
487        let weak = Arc::downgrade(&self.inner);
488        Arc::new(move |org: &OrgId, stack: &str| {
489            let inner = weak.upgrade()?;
490            Apps { inner }.dns_scope(org, stack)
491        })
492    }
493
494    /// An environment's compose stacks and the names they lost.
495    pub fn environment_compose(&self, org: &OrgId, project: &str, env: &str) -> EnvCompose {
496        let owners = self.owners(org);
497        let defs = self.org_defs(org);
498        let app_stack = format!("{project}-{env}");
499        let members = self.members(&owners, &defs, project, env);
500        let lost = contest(&app_stack, &Self::app_names(&defs, &app_stack), &members);
501        EnvCompose {
502            stacks: members.into_iter().map(|(s, _, svcs)| (s, svcs)).collect(),
503            conflicts: lost
504                .into_iter()
505                .map(|((stack, service), winner)| Conflict {
506                    service,
507                    stack,
508                    winner,
509                })
510                .collect(),
511        }
512    }
513
514    /// Before compose stack `stack` is deployed into `project`/`env` with
515    /// `services`: refuse a stack the apps run, and a service name the
516    /// environment already gives an app or another stack's service. Names
517    /// the stack holds already (deployed before) are its own.
518    pub fn compose_check(
519        &self,
520        org: &OrgId,
521        project: &str,
522        env: &str,
523        stack: &str,
524        services: &[String],
525    ) -> Result<()> {
526        if self.managed_by(org, stack) == Some("apps") || format!("{project}-{env}") == stack {
527            return Err(Error::invalid(format!(
528                "stack {stack} is run by a project's apps; change it through them (app_update, app_deploy)"
529            )));
530        }
531        let defs = self.org_defs(org);
532        let before: BTreeSet<String> = match defs.get(stack) {
533            Some(d)
534                if self
535                    .owners(org)
536                    .get(stack)
537                    .is_some_and(|o| o.project == project && o.environment == env) =>
538            {
539                d.file.services.keys().map(|s| label(s)).collect()
540            }
541            _ => BTreeSet::new(),
542        };
543        let apps: BTreeSet<String> = self
544            .list(org)?
545            .into_iter()
546            .filter(|a| a.spec.project == project && a.spec.environment == env)
547            .map(|a| a.spec.name)
548            .collect();
549        let owners = self.owners(org);
550        let others: Vec<(String, u64, Vec<String>)> = self
551            .members(&owners, &defs, project, env)
552            .into_iter()
553            .filter(|(s, _, _)| s != stack)
554            .collect();
555        for svc in services {
556            let l = label(svc);
557            if before.contains(&l) {
558                continue;
559            }
560            if apps.contains(&l) {
561                return Err(Error::invalid(format!(
562                    "service {svc} of stack {stack}: {project}/{env} has an app named {l}, and both would be {l}.{project}-{env}; rename one"
563                )));
564            }
565            for (o, _, osvcs) in &others {
566                if let Some(os) = osvcs.iter().find(|s| label(s) == l) {
567                    return Err(Error::invalid(format!(
568                        "service {svc} of stack {stack}: stack {o} in {project}/{env} has a service {os}, and both would be {l}.{project}-{env}; rename one"
569                    )));
570                }
571            }
572        }
573        Ok(())
574    }
575
576    /// Before an app named `app` is made in `project`/`env`: refuse a name
577    /// one of the environment's compose stacks gives a service.
578    pub(super) fn check_app_name_free(
579        &self,
580        org: &OrgId,
581        project: &str,
582        env: &str,
583        app: &str,
584    ) -> Result<()> {
585        let owners = self.owners(org);
586        let defs = self.org_defs(org);
587        for (stack, _, services) in self.members(&owners, &defs, project, env) {
588            if let Some(s) = services.iter().find(|s| label(s) == label(app)) {
589                return Err(Error::invalid(format!(
590                    "app {app}: stack {stack} in {project}/{env} has a service {s}, and both would be {app}.{project}-{env}; rename one"
591                )));
592            }
593        }
594        Ok(())
595    }
596
597    /// The deployed compose stacks of a project (of one environment, with
598    /// `env`), by name.
599    pub(super) fn live_compose(
600        &self,
601        org: &OrgId,
602        project: &str,
603        env: Option<&str>,
604    ) -> Vec<String> {
605        self.owners(org)
606            .iter()
607            .filter(|(_, o)| o.project == project && env.is_none_or(|e| o.environment == e))
608            .filter(|(s, _)| self.stack_live(org, s))
609            .map(|(s, _)| s.clone())
610            .collect()
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use std::path::Path;
618    use std::time::Duration;
619
620    use crate::client::Client;
621    use crate::secrets::Secrets;
622    use crate::stack::{Controller, Store};
623
624    /// Apps over a controller (no incusd) that has `stacks` deployed in
625    /// org acme: `(name, compose YAML)`.
626    fn apps_with(dir: &Path, stacks: &[(&str, &str)]) -> Apps {
627        let store = Store::open(dir).unwrap();
628        for (name, y) in stacks {
629            store
630                .save(&StackDef {
631                    source: None,
632                    domains: Default::default(),
633                    name: (*name).into(),
634                    org: OrgId::new("acme").unwrap(),
635                    file: serde_yaml_ng::from_str(y).unwrap(),
636                    base_dir: "/".into(),
637                    secrets: Default::default(),
638                    force: Default::default(),
639                    images: Default::default(),
640                    deployed_at: 0,
641                    deployed_by: String::new(),
642                    previous: None,
643                })
644                .unwrap();
645        }
646        let k = crate::secrets::Keyring::new(age::x25519::Identity::generate(), vec![]);
647        let secrets = Arc::new(Secrets::new(crate::secrets::LocalDriver::new(
648            dir,
649            Arc::new(k),
650        )));
651        let client = Client::with_socket("/nonexistent/isb-test/incus.sock");
652        let ctl = Controller::start(
653            client.clone(),
654            store,
655            Duration::from_secs(3600),
656            secrets.clone(),
657        )
658        .unwrap();
659        Apps::new(dir, client, ctl, secrets)
660    }
661
662    fn acme() -> OrgId {
663        OrgId::new("acme").unwrap()
664    }
665
666    fn all(ap: &Apps) -> Vec<(OrgId, String)> {
667        ap.controller()
668            .definitions()
669            .iter()
670            .map(|d| (d.org.clone(), d.name.clone()))
671            .collect()
672    }
673
674    fn owner(ap: &Apps, s: &str) -> Option<(String, String)> {
675        ap.compose_owner(&acme(), s)
676            .map(|o| (o.project, o.environment))
677    }
678
679    fn pe(p: &str, e: &str) -> Option<(String, String)> {
680        Some((p.into(), e.into()))
681    }
682
683    const APP_WEB: &str = "services:\n  web: {image: x, labels: {isb.app: web}}\n";
684
685    #[test]
686    fn adoption_is_metadata_once_and_skips_what_apps_run() {
687        let dir = tempfile::tempdir().unwrap();
688        let ap = apps_with(
689            dir.path(),
690            &[
691                (
692                    "wiki",
693                    "services:\n  redis: {image: x}\n  web: {image: x}\n",
694                ),
695                (
696                    "chat-production",
697                    "services:\n  chat-postgres: {image: x}\n",
698                ),
699                ("isb-tunnel", "services:\n  cloudflared: {image: x}\n"),
700                ("shop-production", APP_WEB),
701                ("shop-production-pr-3", APP_WEB),
702                ("blog", "services:\n  ghost: {image: x}\n"),
703                (
704                    "a-rather-long-stack-name-here",
705                    "services:\n  w: {image: x}\n",
706                ),
707            ],
708        );
709        let org = acme();
710        ap.project_create(&org, "shop", "", &[]).unwrap();
711        ap.project_create(&org, "blog", "", &["staging".into()])
712            .unwrap();
713        let revs: Vec<_> = ap
714            .controller()
715            .definitions()
716            .iter()
717            .map(|d| (d.name.clone(), d.file.clone()))
718            .collect();
719        let msgs = ap.adopt_compose_stacks(&all(&ap));
720        assert_eq!(msgs.len(), 4, "{msgs:?}");
721        assert!(msgs.iter().all(Result::is_ok), "{msgs:?}");
722        assert_eq!(owner(&ap, "wiki"), pe("wiki", "production"));
723        assert_eq!(
724            owner(&ap, "chat-production"),
725            pe("chat-production", "production")
726        );
727        // A project of the stack's name: its first environment when it has no production.
728        assert_eq!(owner(&ap, "blog"), pe("blog", "staging"));
729        assert_eq!(
730            owner(&ap, "a-rather-long-stack-name-here"),
731            pe("a-rather-long-stack", "production")
732        );
733        for s in ["isb-tunnel", "shop-production", "shop-production-pr-3"] {
734            assert_eq!(owner(&ap, s), None, "{s}");
735        }
736        // Nothing was deployed: the definitions are as they were.
737        let after: Vec<_> = ap
738            .controller()
739            .definitions()
740            .iter()
741            .map(|d| (d.name.clone(), d.file.clone()))
742            .collect();
743        assert_eq!(revs, after);
744        // Again: nothing to do, nothing written.
745        let snapshot = ap.project_list(&org).unwrap();
746        assert!(ap.adopt_compose_stacks(&all(&ap)).is_empty());
747        assert_eq!(ap.project_list(&org).unwrap(), snapshot);
748        // The environment name, for every service of wiki.
749        let sc = ap.dns_scope(&org, "wiki").unwrap();
750        assert_eq!(sc.name, "wiki-production");
751        assert_eq!(sc.alias, ["redis".to_string(), "web".to_string()].into());
752        assert!(ap.dns_scope(&org, "shop-production").is_none());
753        let f = ap.scope_fn();
754        assert_eq!(f(&org, "wiki"), Some(sc));
755        // project_create of a name whose environment is a compose stack.
756        let e = ap.project_create(&org, "chat", "", &[]).unwrap_err();
757        assert!(e.to_string().contains("compose stack"), "{e}");
758        let e = ap.environment_create(&org, "blog", "x").err();
759        assert!(e.is_none());
760    }
761
762    #[test]
763    fn one_owner_and_the_guards_it_brings() {
764        let dir = tempfile::tempdir().unwrap();
765        let ap = apps_with(dir.path(), &[("wiki", "services:\n  redis: {image: x}\n")]);
766        let org = acme();
767        ap.project_create(&org, "docs", "", &["production".into(), "staging".into()])
768            .unwrap();
769        assert!(
770            ap.compose_attach(&org, "docs", "qa", "wiki", false)
771                .is_err(),
772            "no such env"
773        );
774        assert!(
775            ap.compose_attach(&org, "nope", "production", "wiki", false)
776                .is_err()
777        );
778        ap.compose_attach(&org, "docs", "staging", "wiki", false)
779            .unwrap();
780        // Again in place: fine. Elsewhere: refused.
781        ap.compose_attach(&org, "docs", "staging", "wiki", false)
782            .unwrap();
783        let e = ap
784            .compose_attach(&org, "docs", "production", "wiki", false)
785            .unwrap_err();
786        assert!(e.to_string().contains("belongs to docs/staging"), "{e}");
787        // An environment's own stack name is the apps'.
788        assert!(
789            ap.compose_attach(&org, "docs", "production", "docs-staging", false)
790                .is_err()
791        );
792        // Guards: the project and environment keep their stacks.
793        let e = ap.project_delete(&org, "docs").unwrap_err();
794        assert!(e.to_string().contains("compose stacks: wiki"), "{e}");
795        assert!(ap.environment_delete(&org, "docs", "staging").is_err());
796        // What a forced delete would take, per environment and in all.
797        let (apps, stacks) = ap.contents(&org, "docs", Some("staging")).unwrap();
798        assert!(apps.is_empty() && stacks == ["wiki"]);
799        assert!(
800            ap.contents(&org, "docs", Some("production"))
801                .unwrap()
802                .1
803                .is_empty()
804        );
805        assert_eq!(ap.contents(&org, "docs", None).unwrap().1, ["wiki"]);
806        ap.environment_delete(&org, "docs", "production").unwrap();
807        // Old JSON (no compose) and new JSON both read.
808        let p = ap.project_get(&org, "docs").unwrap();
809        assert_eq!(p.compose.len(), 1);
810        let old: Project =
811            serde_json::from_str(r#"{"name":"x","environments":["production"],"created_at":1}"#)
812                .unwrap();
813        assert!(old.compose.is_empty());
814        assert!(!serde_json::to_string(&old).unwrap().contains("compose"));
815        // Detach: no owner, the guards lift; detaching again is nothing.
816        ap.compose_detach(&org, "wiki").unwrap();
817        assert_eq!(owner(&ap, "wiki"), None);
818        ap.compose_detach(&org, "wiki").unwrap();
819        ap.project_delete(&org, "docs").unwrap();
820        // create_project makes it in the same write.
821        ap.compose_attach(&org, "kb", "production", "wiki", true)
822            .unwrap();
823        assert_eq!(
824            ap.project_get(&org, "kb").unwrap().environments,
825            ["production"]
826        );
827        // A record of a stack that is gone neither shows nor blocks.
828        ap.controller()
829            .remove("acme/wiki", false, Duration::from_secs(5))
830            .ok();
831        assert!(ap.project_list(&org).unwrap()[0].compose.is_empty());
832        ap.project_delete(&org, "kb").unwrap();
833    }
834
835    #[test]
836    fn default_owner_prefers_the_project_of_the_name() {
837        let dir = tempfile::tempdir().unwrap();
838        let ap = apps_with(
839            dir.path(),
840            &[("wiki-production", "services:\n  w: {image: x}\n")],
841        );
842        let org = acme();
843        assert_eq!(
844            ap.default_owner(&org, "shop").unwrap(),
845            ("shop".into(), "production".into(), true)
846        );
847        ap.project_create(&org, "shop", "", &["staging".into(), "production".into()])
848            .unwrap();
849        assert_eq!(
850            ap.default_owner(&org, "shop").unwrap(),
851            ("shop".into(), "production".into(), false)
852        );
853        // `wiki-production` is a compose stack, so project wiki is taken.
854        assert_eq!(
855            ap.default_owner(&org, "wiki").unwrap(),
856            ("wiki-2".into(), "production".into(), true)
857        );
858    }
859
860    #[test]
861    fn names_in_an_environment_have_one_holder() {
862        let dir = tempfile::tempdir().unwrap();
863        let ap = apps_with(
864            dir.path(),
865            &[
866                ("shop-production", APP_WEB),
867                (
868                    "cache",
869                    "services:\n  web: {image: x}\n  redis: {image: x}\n",
870                ),
871                ("queue", "services:\n  redis: {image: x}\n"),
872            ],
873        );
874        let org = acme();
875        ap.project_create(&org, "shop", "", &[]).unwrap();
876        // Joined before the rules: both kept, the tie-break decides.
877        ap.compose_attach(&org, "shop", "production", "cache", false)
878            .unwrap();
879        ap.compose_attach(&org, "shop", "production", "queue", false)
880            .unwrap();
881        let sc = ap.dns_scope(&org, "cache").unwrap();
882        assert_eq!(sc.alias, ["redis".to_string()].into(), "the app holds web");
883        let q = ap.dns_scope(&org, "queue").unwrap();
884        // Same second: the stack name decides (cache < queue).
885        assert!(q.alias.is_empty());
886        let ec = ap.environment_compose(&org, "shop", "production");
887        assert_eq!(
888            ec.stacks,
889            vec![
890                (
891                    "cache".to_string(),
892                    vec!["redis".to_string(), "web".to_string()]
893                ),
894                ("queue".to_string(), vec!["redis".to_string()]),
895            ]
896        );
897        assert_eq!(
898            ec.conflicts,
899            vec![
900                Conflict {
901                    service: "web".into(),
902                    stack: "cache".into(),
903                    winner: "shop-production".into()
904                },
905                Conflict {
906                    service: "redis".into(),
907                    stack: "queue".into(),
908                    winner: "cache".into()
909                },
910            ]
911        );
912        let svcs = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
913        // A redeploy keeps what it had.
914        ap.compose_check(
915            &org,
916            "shop",
917            "production",
918            "cache",
919            &svcs(&["web", "redis"]),
920        )
921        .unwrap();
922        // A new stack, or a new service, may not take a held name.
923        let e = ap
924            .compose_check(&org, "shop", "production", "jobs", &svcs(&["redis"]))
925            .unwrap_err();
926        assert!(e.to_string().contains("stack cache"), "{e}");
927        let e = ap
928            .compose_check(&org, "shop", "production", "jobs", &svcs(&["web"]))
929            .unwrap_err();
930        assert!(
931            e.to_string().contains("stack cache") || e.to_string().contains("app"),
932            "{e}"
933        );
934        ap.compose_check(&org, "shop", "production", "jobs", &svcs(&["worker"]))
935            .unwrap();
936        // Nor the apps' stack itself.
937        assert!(
938            ap.compose_check(&org, "shop", "production", "shop-production", &svcs(&["x"]))
939                .is_err()
940        );
941        // And an app may not take a compose service's name.
942        let spec: super::super::AppSpec = serde_json::from_value(serde_json::json!({
943            "name": "redis", "project": "shop", "source": {"image": "x"}
944        }))
945        .unwrap();
946        let e = ap.create(&org, spec).unwrap_err();
947        assert!(e.to_string().contains("stack cache"), "{e}");
948        let spec: super::super::AppSpec = serde_json::from_value(serde_json::json!({
949            "name": "api", "project": "shop", "source": {"image": "x"}
950        }))
951        .unwrap();
952        ap.create(&org, spec).unwrap();
953        let e = ap
954            .compose_check(&org, "shop", "production", "jobs", &svcs(&["api"]))
955            .unwrap_err();
956        assert!(e.to_string().contains("an app named api"), "{e}");
957    }
958
959    #[test]
960    fn candidates_fit_a_stack_name() {
961        assert_eq!(project_candidate("wiki", 1), "wiki");
962        assert_eq!(project_candidate("wiki", 2), "wiki-2");
963        // 19 characters leave room for `-production`.
964        let long = "a-rather-long-stack-name-here";
965        let c = project_candidate(long, 1);
966        assert_eq!(c, "a-rather-long-stack");
967        assert!(format!("{c}-production").len() <= 30);
968        let c2 = project_candidate(long, 2);
969        assert_eq!(c2, "a-rather-long-sta-2");
970        assert!(format!("{c2}-production").len() <= 30);
971        // No dash before the cut's end.
972        assert_eq!(
973            project_candidate("abcdefghijklmnopqr-stuvwxyz", 1),
974            "abcdefghijklmnopqr"
975        );
976        assert_eq!(project_candidate("chat-production", 1), "chat-production");
977    }
978
979    #[test]
980    fn contested_names_go_to_apps_then_the_first_to_join() {
981        let apps: BTreeSet<String> = ["web".to_string()].into();
982        let m = vec![
983            (
984                "b".to_string(),
985                5,
986                vec!["redis".to_string(), "web".to_string()],
987            ),
988            ("a".to_string(), 5, vec!["redis".to_string()]),
989            ("c".to_string(), 1, vec!["my_db".to_string()]),
990            ("d".to_string(), 9, vec!["my-db".to_string()]),
991        ];
992        let lost = contest("shop-production", &apps, &m);
993        assert_eq!(lost[&("b".into(), "web".into())], "shop-production");
994        // Same join time: the stack name decides.
995        assert_eq!(lost[&("b".into(), "redis".into())], "a");
996        // By DNS label, not by spelling.
997        assert_eq!(lost[&("d".into(), "my-db".into())], "c");
998        assert_eq!(lost.len(), 3);
999    }
1000}