1use std::collections::{BTreeMap, BTreeSet};
19
20use serde::Serialize;
21use serde_json::{Value, json};
22use serde_yaml_ng::Value as Y;
23
24use super::shared::*;
25pub use super::shared::{Report, Status, image_ref, key_name};
26use super::{AppTemplate, FileTemplate, Template, VarKind, Variable};
27use crate::app::Resources;
28
29#[derive(Debug, Clone, Default, PartialEq, Serialize, serde::Deserialize)]
31pub struct Meta {
32 pub id: String,
33 #[serde(default)]
34 pub name: String,
35 #[serde(default)]
36 pub version: String,
37 #[serde(default)]
38 pub description: String,
39 #[serde(default)]
40 pub logo: Option<String>,
41 #[serde(default)]
42 pub links: BTreeMap<String, String>,
43 #[serde(default)]
44 pub tags: Vec<String>,
45}
46
47#[derive(Debug, Clone, PartialEq)]
49enum Helper {
50 Domain,
51 Password(u32),
52 Base64(u32),
53 Hash(u32),
54 Uuid,
55 RandomPort,
56 Email,
57 Username,
58 Timestamp {
59 ms: bool,
60 at: Option<String>,
61 },
62 JwtHex(u32),
63 Jwt {
64 secret: Option<String>,
65 payload: Option<String>,
66 },
67}
68
69fn parse_helper(inner: &str, vars: &BTreeMap<String, String>) -> Option<Helper> {
70 let (head, arg) = match inner.split_once(':') {
71 Some((h, a)) => (h, Some(a)),
72 None => (inner, None),
73 };
74 let n = |d: u32| -> Option<u32> {
75 match arg {
76 None => Some(d),
77 Some(a) => a.parse().ok().or(Some(d)),
78 }
79 };
80 Some(match head {
81 "domain" if arg.is_none() => Helper::Domain,
82 "password" => Helper::Password(n(16)?),
83 "base64" => Helper::Base64(n(32)?),
84 "hash" => Helper::Hash(n(8)?),
85 "uuid" if arg.is_none() => Helper::Uuid,
86 "randomPort" if arg.is_none() => Helper::RandomPort,
87 "email" if arg.is_none() => Helper::Email,
88 "username" if arg.is_none() => Helper::Username,
89 "timestamp" | "timestampms" => Helper::Timestamp {
90 ms: true,
91 at: arg.map(String::from),
92 },
93 "timestamps" => Helper::Timestamp {
94 ms: false,
95 at: arg.map(String::from),
96 },
97 "jwt" => match arg {
98 None => Helper::Jwt {
99 secret: None,
100 payload: None,
101 },
102 Some(a) if a.len() <= 3 && a.chars().all(|c| c.is_ascii_digit()) => {
103 Helper::JwtHex(a.parse().ok()?)
104 }
105 Some(a) => {
106 let (s, p) = match a.split_once(':') {
107 Some((s, p)) => (s, Some(p)),
108 None => (a, None),
109 };
110 if !vars.contains_key(s) {
111 return None;
112 }
113 Helper::Jwt {
114 secret: Some(s.to_string()),
115 payload: p.filter(|p| vars.contains_key(*p)).map(String::from),
116 }
117 }
118 },
119 _ => return None,
120 })
121}
122
123#[derive(Debug, Clone, PartialEq)]
125enum DPart {
126 Lit(String),
127 Ref(String),
128 Helper(Helper),
129}
130
131fn dparse(s: &str, vars: &BTreeMap<String, String>) -> Vec<DPart> {
135 let mut out = Vec::new();
136 let mut rest = s;
137 let mut lit_buf = String::new();
138 while let Some(i) = rest.find("${") {
139 let Some(end) = rest[i + 2..].find('}') else {
140 break;
141 };
142 let inner = &rest[i + 2..i + 2 + end];
143 lit_buf.push_str(&rest[..i]);
144 let part = if vars.contains_key(inner) {
145 Some(DPart::Ref(inner.to_string()))
146 } else {
147 parse_helper(inner, vars).map(DPart::Helper)
148 };
149 match part {
150 Some(p) => {
151 if !lit_buf.is_empty() {
152 out.push(DPart::Lit(std::mem::take(&mut lit_buf)));
153 }
154 out.push(p);
155 }
156 None => lit_buf.push_str(&rest[i..i + 2 + end + 1]),
157 }
158 rest = &rest[i + 2 + end + 1..];
159 }
160 lit_buf.push_str(rest);
161 if !lit_buf.is_empty() {
162 out.push(DPart::Lit(lit_buf));
163 }
164 out
165}
166
167impl Tx {
168 fn helper_var(&mut self, h: &Helper, base: &str) -> String {
170 let name = self.fresh_name(base);
171 let mut v = Variable {
172 name: name.clone(),
173 ..Default::default()
174 };
175 match h {
176 Helper::Domain => v.kind = VarKind::Domain,
177 Helper::Password(n) => {
178 v.kind = VarKind::Password;
179 v.length = Some(*n);
180 }
181 Helper::Base64(n) => {
182 v.kind = VarKind::Base64;
183 v.bytes = Some(*n);
184 }
185 Helper::Hash(n) | Helper::JwtHex(n) => {
186 v.kind = VarKind::Hex;
187 v.bytes = Some(*n);
188 v.secret = Some(true);
189 }
190 Helper::Uuid => {
191 v.kind = VarKind::Uuid;
192 v.secret = Some(secretish(base));
193 }
194 Helper::RandomPort => v.kind = VarKind::Port,
195 Helper::Username => v.kind = VarKind::Username,
196 Helper::Email => {
197 let u = self.fresh_name(&format!("{base}_user"));
198 self.vars.push(Variable {
199 name: u.clone(),
200 kind: VarKind::Username,
201 ..Default::default()
202 });
203 v.kind = VarKind::Email;
204 v.default = Some(format!("${{{u}}}@example.com"));
205 }
206 Helper::Timestamp { ms, at } => {
207 v.kind = VarKind::Timestamp;
208 v.unit = Some(if *ms { "ms" } else { "s" }.into());
209 v.at = at.as_ref().map(|a| lit(a));
210 }
211 Helper::Jwt { secret, payload } => {
212 v.kind = VarKind::Jwt;
213 let secret = match secret {
214 Some(s) => self.names.get(s).cloned().unwrap_or_else(|| var_name(s)),
215 None => {
216 let s = self.fresh_name(&format!("{base}_secret"));
217 self.vars.push(Variable {
218 name: s.clone(),
219 kind: VarKind::Password,
220 length: Some(32),
221 ..Default::default()
222 });
223 s
224 }
225 };
226 v.jwt = Some(super::JwtSpec {
227 secret,
228 payload: payload.as_ref().map(|p| {
229 format!(
230 "${{{}}}",
231 self.names.get(p).cloned().unwrap_or_else(|| var_name(p))
232 )
233 }),
234 });
235 }
236 }
237 self.vars.push(v);
238 name
239 }
240
241 fn expr(&mut self, s: &str, base: &str, dvars: &BTreeMap<String, String>) -> String {
244 let mut out = String::new();
245 for p in dparse(s, dvars) {
246 match p {
247 DPart::Lit(l) => out.push_str(&lit(&l)),
248 DPart::Ref(r) => {
249 let n = self.names.get(&r).cloned().unwrap_or_else(|| var_name(&r));
250 out.push_str(&format!("${{{n}}}"));
251 }
252 DPart::Helper(h) => {
253 let n = self.helper_var(&h, base);
254 out.push_str(&format!("${{{n}}}"));
255 }
256 }
257 }
258 out
259 }
260}
261
262fn scalar(v: &toml::Value) -> String {
263 match v {
264 toml::Value::String(s) => s.clone(),
265 toml::Value::Integer(i) => i.to_string(),
266 toml::Value::Float(f) => f.to_string(),
267 toml::Value::Boolean(b) => b.to_string(),
268 other => format!("{other:?}"),
269 }
270}
271
272#[expect(
275 clippy::too_many_lines,
276 reason = "predates the lint ratchet; split it when next changed"
277)]
278fn interpolate(
279 s: &str,
280 env: &BTreeMap<String, String>,
281 unset: &mut BTreeSet<String>,
282) -> std::result::Result<String, String> {
283 let mut out = String::new();
284 let b = s.as_bytes();
285 let mut i = 0;
286 while i < b.len() {
287 if b[i] != b'$' {
288 let c = s[i..].chars().next().unwrap_or('\0');
289 out.push_str(&lit(&c.to_string()));
290 i += c.len_utf8();
291 continue;
292 }
293 match b.get(i + 1) {
294 Some(b'$') => {
295 out.push_str("$$");
296 i += 2;
297 }
298 Some(b'{') => {
299 let mut depth = 0;
301 let mut j = i + 1;
302 let mut end = None;
303 while j < b.len() {
304 match b[j] {
305 b'{' => depth += 1,
306 b'}' => {
307 depth -= 1;
308 if depth == 0 {
309 end = Some(j);
310 break;
311 }
312 }
313 _ => {}
314 }
315 j += 1;
316 }
317 let end = end.ok_or_else(|| format!("unterminated ${{ in {s:?}"))?;
318 let inner = &s[i + 2..end];
319 let name_end = inner
320 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
321 .unwrap_or(inner.len());
322 let (name, op) = inner.split_at(name_end);
323 if name.is_empty() {
324 return Err(format!("${{{inner}}} is not a variable"));
325 }
326 let set = env.get(name);
327 let empty = set.is_none_or(|v| v.is_empty());
328 let pick = |alt: &str, unset: &mut BTreeSet<String>| interpolate(alt, env, unset);
329 let val = if let Some(d) = op.strip_prefix(":-") {
330 if empty {
331 pick(d, unset)?
332 } else {
333 set.cloned().unwrap_or_default()
334 }
335 } else if let Some(d) = op.strip_prefix('-') {
336 match set {
337 Some(v) => v.clone(),
338 None => pick(d, unset)?,
339 }
340 } else if let Some(a) = op.strip_prefix(":+") {
341 if empty {
342 String::new()
343 } else {
344 pick(a, unset)?
345 }
346 } else if let Some(a) = op.strip_prefix('+') {
347 if set.is_some() {
348 pick(a, unset)?
349 } else {
350 String::new()
351 }
352 } else if op.starts_with(":?") || op.starts_with('?') || op.is_empty() {
353 match set {
354 Some(v) => v.clone(),
355 None => {
356 unset.insert(name.to_string());
357 String::new()
358 }
359 }
360 } else {
361 return Err(format!("${{{inner}}}: unsupported interpolation"));
362 };
363 out.push_str(&val);
364 i = end + 1;
365 }
366 Some(c) if c.is_ascii_alphabetic() || *c == b'_' => {
367 let start = i + 1;
368 let mut j = start;
369 while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
370 j += 1;
371 }
372 let name = &s[start..j];
373 match env.get(name) {
374 Some(v) => out.push_str(v),
375 None => {
376 unset.insert(name.to_string());
377 }
378 }
379 i = j;
380 }
381 _ => {
382 out.push_str("$$");
383 i += 1;
384 }
385 }
386 }
387 Ok(out)
388}
389
390struct Toml {
392 vars: BTreeMap<String, String>,
393 env: Vec<(String, String)>,
394 domains: Vec<(String, u16, String, String)>,
395 mounts: BTreeMap<String, String>,
396}
397
398#[expect(
399 clippy::too_many_lines,
400 reason = "predates the lint ratchet; split it when next changed"
401)]
402fn parse_toml(text: &str, tx: &mut Tx) -> Option<Toml> {
403 let t: toml::Table = match text.parse() {
404 Ok(t) => t,
405 Err(e) => {
406 tx.refuse(format!("template.toml does not parse: {e}"));
407 return None;
408 }
409 };
410 let mut out = Toml {
411 vars: BTreeMap::new(),
412 env: Vec::new(),
413 domains: Vec::new(),
414 mounts: BTreeMap::new(),
415 };
416 if let Some(v) = t.get("variables").and_then(|v| v.as_table()) {
417 for (k, v) in v {
418 out.vars.insert(k.clone(), scalar(v));
419 }
420 }
421 let cfg = t.get("config").and_then(|v| v.as_table());
422 for k in t.keys() {
423 if k != "variables" && k != "config" {
424 tx.note(format!(
425 "template.toml section [{k}] is not used by Dokploy or isb"
426 ));
427 }
428 }
429 let Some(cfg) = cfg else { return Some(out) };
430 match cfg.get("env") {
431 Some(toml::Value::Table(e)) => {
432 for (k, v) in e {
433 out.env.push((k.clone(), scalar(v)));
434 }
435 }
436 Some(toml::Value::Array(a)) => {
437 for item in a {
438 match item {
439 toml::Value::String(s) => match s.split_once('=') {
440 Some((k, v)) => out.env.push((k.trim().to_string(), v.to_string())),
441 None => out.env.push((s.trim().to_string(), String::new())),
442 },
443 toml::Value::Table(t) => {
444 for (k, v) in t {
445 out.env.push((k.clone(), scalar(v)));
446 }
447 }
448 other => tx.refuse(format!("config.env entry {other:?} is not KEY=VALUE")),
449 }
450 }
451 }
452 Some(other) => tx.refuse(format!("config.env is a {}", other.type_str())),
453 None => {}
454 }
455 if let Some(ds) = cfg.get("domains").and_then(|v| v.as_array()) {
456 for d in ds {
457 let get = |k: &str| d.get(k).map(scalar);
458 let Some(svc) = get("serviceName") else {
459 tx.refuse("a config.domains entry lacks serviceName");
460 continue;
461 };
462 let host = get("host").filter(|h| !h.is_empty()).unwrap_or_else(|| {
463 tx.note(format!(
464 "config.domains for {svc} has no host; it gets a generated one"
465 ));
466 "${domain}".into()
467 });
468 let port = match d.get("port").and_then(|p| p.as_integer()) {
469 Some(p) if (1..=65535).contains(&p) => p as u16,
470 _ => {
471 tx.refuse(format!("config.domains for {svc}: no valid port"));
472 continue;
473 }
474 };
475 let path = get("path")
476 .filter(|p| !p.is_empty())
477 .unwrap_or_else(|| "/".into());
478 for k in d
479 .as_table()
480 .map(|t| t.keys().cloned().collect::<Vec<_>>())
481 .unwrap_or_default()
482 {
483 if !matches!(k.as_str(), "serviceName" | "host" | "port" | "path") {
484 tx.note(format!("config.domains key {k} is ignored"));
485 }
486 }
487 out.domains.push((svc, port, host, path));
488 }
489 }
490 if let Some(ms) = cfg.get("mounts").and_then(|v| v.as_array()) {
491 for m in ms {
492 let path = m.get("filePath").map(scalar);
493 let content = m.get("content").map(scalar);
494 match (path, content) {
495 (Some(p), Some(c)) => {
496 out.mounts.insert(
497 p.trim_start_matches("./")
498 .trim_start_matches('/')
499 .to_string(),
500 c,
501 );
502 }
503 _ => tx.refuse("a config.mounts entry lacks filePath or content"),
504 }
505 }
506 }
507 for k in cfg.keys() {
508 if !matches!(k.as_str(), "env" | "domains" | "mounts" | "isolated") {
509 tx.note(format!("config.{k} is ignored"));
510 }
511 }
512 Some(out)
513}
514
515fn traefik_rule(rule: &str) -> Option<(Vec<String>, Option<String>)> {
517 let mut hosts = Vec::new();
518 let mut path = None;
519 if rule.contains("||") || rule.contains('!') {
520 return None;
521 }
522 for part in rule.split("&&") {
523 let part = part
524 .trim()
525 .trim_start_matches('(')
526 .trim_end_matches(')')
527 .trim();
528 let (f, args) = part.split_once('(')?;
529 let args: Vec<String> = args
530 .trim_end_matches(')')
531 .split(',')
532 .map(|a| a.trim().trim_matches(['`', '"', '\'']).to_string())
533 .filter(|a| !a.is_empty())
534 .collect();
535 match f.trim() {
536 "Host" => hosts.extend(args),
537 "PathPrefix" | "Path" if args.len() == 1 && path.is_none() => {
538 path = Some(args[0].clone())
539 }
540 _ => return None,
541 }
542 }
543 (!hosts.is_empty()).then_some((hosts, path))
544}
545
546#[expect(
548 clippy::too_many_lines,
549 reason = "predates the lint ratchet; split it when next changed"
550)]
551fn traefik_domains(
552 svc: &str,
553 labels: &[(String, String)],
554 tx: &mut Tx,
555) -> Vec<serde_json::Map<String, Value>> {
556 let get = |k: &str| labels.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone());
557 let enabled = get("traefik.enable").is_none_or(|v| v != "false");
558 let mut routers: BTreeSet<String> = BTreeSet::new();
559 let mut ports: BTreeMap<String, u16> = BTreeMap::new();
560 let mut middlewares: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
561 for (k, v) in labels {
562 let Some(rest) = k.strip_prefix("traefik.") else {
563 continue;
564 };
565 if rest.starts_with("tcp.") || rest.starts_with("udp.") {
566 tx.refuse(format!("{svc}: Traefik TCP/UDP routing ({k})"));
567 } else if let Some(r) = rest.strip_prefix("http.routers.") {
568 if let Some((name, _)) = r.split_once('.') {
569 routers.insert(name.to_string());
570 }
571 } else if let Some(s) = rest.strip_prefix("http.services.") {
572 if let Some((name, field)) = s.split_once('.') {
573 if field == "loadbalancer.server.port" {
574 if let Ok(p) = v.parse() {
575 ports.insert(name.to_string(), p);
576 }
577 }
578 }
579 } else if let Some(m) = rest.strip_prefix("http.middlewares.") {
580 if let Some((name, field)) = m.split_once('.') {
581 middlewares
582 .entry(name.to_string())
583 .or_default()
584 .push((field.to_string(), v.clone()));
585 }
586 }
587 }
588 if !enabled {
589 return vec![];
590 }
591 let mut out = Vec::new();
592 for r in routers {
593 let field = |f: &str| get(&format!("traefik.http.routers.{r}.{f}"));
594 let Some(rule) = field("rule") else { continue };
595 let Some((hosts, path)) = traefik_rule(&rule) else {
596 tx.refuse(format!(
597 "{svc}: Traefik rule {rule:?} is more than Host(...) && PathPrefix(...)"
598 ));
599 continue;
600 };
601 let port = field("service")
602 .and_then(|s| ports.get(&s).copied())
603 .or_else(|| (ports.len() == 1).then(|| *ports.values().next().expect("one")));
604 let Some(port) = port else {
605 tx.refuse(format!(
606 "{svc}: Traefik router {r} has no loadbalancer.server.port to send to"
607 ));
608 continue;
609 };
610 let entry = field("entrypoints").unwrap_or_default();
611 let web_only = !entry.is_empty() && entry.split(',').all(|e| e.trim() == "web");
613 let https = !web_only || field("tls").is_some_and(|t| t == "true");
614 let mut strip = false;
615 for m in field("middlewares")
616 .unwrap_or_default()
617 .split(',')
618 .map(|m| m.trim().split('@').next().unwrap_or("").to_string())
619 .filter(|m| !m.is_empty())
620 {
621 let fields = middlewares.get(&m).cloned().unwrap_or_default();
622 let kind = fields
623 .first()
624 .map(|(f, _)| f.split('.').next().unwrap_or("").to_ascii_lowercase());
625 match kind.as_deref() {
626 Some("stripprefix") => strip = true,
627 Some("redirectscheme") => {
628 tx.note(format!(
629 "{svc}: Traefik redirectscheme is isb's default for an https domain"
630 ));
631 }
632 Some(k) => tx.refuse(format!("{svc}: Traefik middleware {m} ({k})")),
633 None => tx.refuse(format!(
634 "{svc}: Traefik middleware {m} is defined elsewhere"
635 )),
636 }
637 }
638 for h in hosts {
639 let mut d = serde_json::Map::new();
640 d.insert("host".into(), json!(h));
641 d.insert("port".into(), json!(port));
642 if let Some(p) = &path {
643 d.insert("path".into(), json!(p));
644 }
645 if !https {
646 d.insert("https".into(), json!(false));
647 }
648 if strip {
649 d.insert("strip_prefix".into(), json!(true));
650 }
651 out.push(d);
652 }
653 }
654 if !out.is_empty() {
655 tx.note(format!("{svc}: Traefik labels are mapped to domains"));
656 }
657 out
658}
659
660pub fn translate(meta: &Meta, compose: &str, toml_text: &str) -> (Option<Template>, Report) {
663 let mut tx = Tx {
664 notes: vec![],
665 refusals: vec![],
666 vars: vec![],
667 names: BTreeMap::new(),
668 };
669 let t = translate_inner(meta, compose, toml_text, &mut tx);
670 finish(t, tx)
671}
672
673fn native_vars(toml: &Toml, tx: &mut Tx) -> BTreeMap<String, String> {
675 for k in toml.vars.keys() {
677 let n = tx.fresh_name(k);
678 tx.names.insert(k.clone(), n.clone());
679 tx.vars.push(Variable {
681 name: n,
682 ..Default::default()
683 });
684 }
685 let dvars = toml.vars.clone();
686 for (k, raw) in &toml.vars {
687 let native = tx.names[k].clone();
688 let parts = dparse(raw, &dvars);
689 let v = match parts.as_slice() {
690 [DPart::Helper(h)] => {
691 tx.vars.retain(|v| v.name != native);
693 let made = tx.helper_var(h, &native);
694 debug_assert_eq!(made, native);
695 if matches!(h, Helper::Uuid) {
696 if let Some(v) = tx.vars.iter_mut().find(|v| v.name == made) {
697 v.secret = Some(secretish(k));
698 }
699 }
700 continue;
701 }
702 [] => Variable {
703 name: native.clone(),
704 default: Some(String::new()),
705 ..Default::default()
706 },
707 [DPart::Lit(l)] => Variable {
708 name: native.clone(),
709 default: Some(lit(l)),
710 secret: Some(secretish(k)),
711 ..Default::default()
712 },
713 _ => {
714 let e = tx.expr(raw, &native, &dvars);
715 Variable {
716 name: native.clone(),
717 default: Some(e),
718 ..Default::default()
719 }
720 }
721 };
722 let i = tx
723 .vars
724 .iter()
725 .position(|x| x.name == native)
726 .expect("reserved");
727 tx.vars[i] = v;
728 }
729 dvars
730}
731
732fn native_env(
734 toml: &Toml,
735 dvars: &BTreeMap<String, String>,
736 tx: &mut Tx,
737) -> (BTreeMap<String, String>, Vec<String>) {
738 let mut env: BTreeMap<String, String> = BTreeMap::new();
739 let mut env_order: Vec<String> = Vec::new();
740 for (k, v) in &toml.env {
741 let e = tx.expr(v, &format!("env_{k}"), dvars);
742 if !env.contains_key(k) {
743 env_order.push(k.clone());
744 }
745 env.insert(k.clone(), e);
746 }
747 (env, env_order)
748}
749
750fn check_services(acc: service::Acc, toml: &Toml, keys: &BTreeMap<String, String>, tx: &mut Tx) {
753 for (v, users) in &acc.vol_users {
754 if users.len() > 1 {
755 tx.refuse(format!(
756 "volume {v} is shared by {} (an app's volumes are its own)",
757 users.iter().cloned().collect::<Vec<_>>().join(", ")
758 ));
759 }
760 }
761 for v in acc.unset {
762 tx.note(format!(
763 "${{{v}}} is not set in the template; it is empty, as in docker compose"
764 ));
765 }
766 for (svc, ..) in &toml.domains {
767 if !keys.contains_key(svc) {
768 tx.refuse(format!(
769 "config.domains names service {svc}, which the compose file lacks"
770 ));
771 }
772 }
773}
774
775fn translate_inner(meta: &Meta, compose: &str, toml_text: &str, tx: &mut Tx) -> Option<Template> {
776 let toml = parse_toml(toml_text, tx)?;
777 let dvars = native_vars(&toml, tx);
778 let (env, env_order) = native_env(&toml, &dvars, tx);
779 let mounts: BTreeMap<String, String> = toml
780 .mounts
781 .iter()
782 .map(|(p, c)| (p.clone(), tx.expr(c, "file", &dvars)))
783 .collect();
784
785 let doc = parse_compose(compose, tx)?;
787 let Some(top) = ymap(&doc) else {
788 tx.refuse("docker-compose.yml is not a mapping");
789 return None;
790 };
791 check_top_level(top, tx);
792 let declared_vols = declared_volumes(top, tx);
793 let Some(services) = yget(top, "services").and_then(ymap) else {
794 tx.refuse("docker-compose.yml has no services");
795 return None;
796 };
797 let (keys, aliases) = service_names(services, tx);
798 let sh = service::Shared {
799 env: &env,
800 env_order: &env_order,
801 mounts: &mounts,
802 declared_vols: &declared_vols,
803 keys: &keys,
804 aliases: &aliases,
805 domains: &toml.domains,
806 dvars: &dvars,
807 };
808 let mut acc = service::Acc {
810 unset: BTreeSet::new(),
811 vol_users: BTreeMap::new(),
812 };
813 let mut apps = Vec::new();
814 for (name, s) in services {
815 let name = yscalar(name).unwrap_or_default();
816 let Some(key) = keys.get(&name).cloned() else {
817 continue;
818 };
819 let Some(m) = ymap(s) else {
820 tx.refuse(format!("service {name} is not a mapping"));
821 continue;
822 };
823 if let Some(app) = service::translate(&sh, &mut acc, &name, key, m, tx) {
824 apps.push(app);
825 }
826 }
827 check_services(acc, &toml, &keys, tx);
828 if apps.is_empty() {
829 tx.refuse("no service to deploy");
830 return None;
831 }
832 if apps.len() > 1 {
833 tx.note("its services reach each other as <app>.<project>-<env> (an org's service names)");
834 }
835 let main = toml
836 .domains
837 .first()
838 .and_then(|(s, ..)| keys.get(s).cloned())
839 .unwrap_or_else(|| apps[0].name.clone());
840 let id = key_name(&meta.id);
842 let logo = meta.logo.clone().filter(|l| l.starts_with("https://"));
843 Some(Template {
844 id,
845 name: if meta.name.is_empty() {
846 meta.id.clone()
847 } else {
848 meta.name.clone()
849 },
850 description: meta.description.clone(),
851 version: meta.version.clone(),
852 logo,
853 tags: meta.tags.clone(),
854 links: meta.links.clone(),
855 variables: tx.vars.clone(),
856 apps,
857 main: Some(main),
858 notes: vec![],
859 })
860}
861
862pub(in crate::template) mod service;
863#[cfg(test)]
864mod tests;