1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct ComposeOwner {
36 pub project: String,
37 pub environment: String,
38 pub added_at: u64,
40}
41
42pub(super) type Owners = BTreeMap<String, ComposeOwner>;
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47pub struct Conflict {
48 pub service: String,
50 pub stack: String,
52 pub winner: String,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct EnvCompose {
60 pub stacks: Vec<(String, Vec<String>)>,
62 pub conflicts: Vec<Conflict>,
63}
64
65fn label(service: &str) -> String {
67 crate::compose::sanitize_name(service)
68}
69
70fn project_candidate(stack: &str, n: u32) -> String {
74 let suffix = if n <= 1 {
75 String::new()
76 } else {
77 format!("-{n}")
78 };
79 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
88fn 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 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 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 pub(super) fn invalidate_owners(&self, org: &OrgId) {
150 self.inner.owners.lock().unwrap().remove(org);
151 }
152
153 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(
789 ap.compose_attach(&org, "docs", "production", "docs-staging", false)
790 .is_err()
791 );
792 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 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 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 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 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 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 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 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 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 ap.compose_check(
915 &org,
916 "shop",
917 "production",
918 "cache",
919 &svcs(&["web", "redis"]),
920 )
921 .unwrap();
922 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 assert!(
938 ap.compose_check(&org, "shop", "production", "shop-production", &svcs(&["x"]))
939 .is_err()
940 );
941 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 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 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 assert_eq!(lost[&("b".into(), "redis".into())], "a");
996 assert_eq!(lost[&("d".into(), "my-db".into())], "c");
998 assert_eq!(lost.len(), 3);
999 }
1000}