Skip to main content

isb_apps/template/
dokploy.rs

1//! Dokploy's template format, translated into isb's.
2//!
3//! A Dokploy template is a `docker-compose.yml` plus a `template.toml`:
4//! `[variables]` (values with helpers such as `${password:32}`,
5//! `${domain}`, `${base64:64}`, `${uuid}`, `${jwt:secret:payload}`),
6//! `[config.env]` (written to the compose project's `.env`, so it feeds
7//! both `${VAR}` interpolation and `env_file: .env`), `[[config.domains]]`
8//! (service, port, host, path) and `[[config.mounts]]` (file contents the
9//! compose file mounts from `../files/<path>`).
10//!
11//! The translation is strict: each compose service becomes one app, and
12//! anything isb's model cannot express or that would weaken isolation
13//! (privileged, capabilities, devices, host namespaces, host paths, the
14//! docker socket, one volume shared by several services, one-shot jobs) is
15//! refused with a reason. What is mapped with a change of meaning is listed
16//! in the report's notes; nothing is dropped silently.
17
18use 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/// A template's metadata from Dokploy's `meta.json`.
30#[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/// One Dokploy helper or reference.
48#[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/// A Dokploy piece of text.
124#[derive(Debug, Clone, PartialEq)]
125enum DPart {
126    Lit(String),
127    Ref(String),
128    Helper(Helper),
129}
130
131/// Split a Dokploy value into literal text, `${variable}` references and
132/// `${helper}`s. Anything else in `${...}` stays literal, as Dokploy
133/// leaves it.
134fn 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    /// A native variable for one helper, named after `base`.
169    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    /// A Dokploy value as a native expression; inline helpers become
242    /// variables named after `base`.
243    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/// Compose `${VAR}` interpolation, against the template's `.env`, written
273/// as a native expression.
274#[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                // Find the matching brace (defaults may nest ${...}).
300                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
390/// A parsed `template.toml`.
391struct 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
515/// A Traefik rule's hosts and path prefix, if it is only that.
516fn 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/// Domains from a service's Traefik labels (already interpolated).
547#[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        // HTTPS unless every entrypoint is plain `web`.
612        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
660/// Translate one Dokploy template. `None` with refusals when it cannot run
661/// on isb.
662pub 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
673/// Each Dokploy variable as a native one; returns the Dokploy variables.
674fn native_vars(toml: &Toml, tx: &mut Tx) -> BTreeMap<String, String> {
675    // Variables first: each Dokploy variable becomes a native one.
676    for k in toml.vars.keys() {
677        let n = tx.fresh_name(k);
678        tx.names.insert(k.clone(), n.clone());
679        // Reserve the name now so helpers do not take it.
680        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                // The variable is the helper: make it under its own name.
692                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
732/// The .env: each entry a native expression, and the order they came in.
733fn 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
750/// What only shows once every service is translated: shared volumes,
751/// variables nothing sets, and domains for services the compose file lacks.
752fn 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    // The compose file.
786    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    // A volume used by two services cannot be two apps' own volumes.
809    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    // Drop variables nothing uses (helpers in an unused env entry).
841    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;