Skip to main content

isb_apps/template/
shared.rs

1//! What the Dokploy and Coolify translations share: the report, the
2//! bookkeeping a translation keeps, compose-file helpers and the compose
3//! keys isb refuses, notes or ignores.
4
5use std::collections::BTreeMap;
6
7use serde::Serialize;
8use serde_yaml_ng::Value as Y;
9
10use super::{Template, Variable};
11
12/// How a translation went.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Status {
16    /// Means the same in isb.
17    Clean,
18    /// Deployable; the notes say what differs.
19    Notes,
20    /// Not deployable; the refusals say why.
21    Refused,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct Report {
26    pub status: Status,
27    #[serde(skip_serializing_if = "Vec::is_empty")]
28    pub notes: Vec<String>,
29    #[serde(skip_serializing_if = "Vec::is_empty")]
30    pub refusals: Vec<String>,
31}
32
33pub(super) struct Tx {
34    pub(super) notes: Vec<String>,
35    pub(super) refusals: Vec<String>,
36    /// Native variables made so far.
37    pub(super) vars: Vec<Variable>,
38    /// Dokploy variable name -> native name.
39    pub(super) names: BTreeMap<String, String>,
40}
41
42impl Tx {
43    pub(super) fn note(&mut self, s: impl Into<String>) {
44        let s = s.into();
45        if !self.notes.contains(&s) {
46            self.notes.push(s);
47        }
48    }
49
50    pub(super) fn refuse(&mut self, s: impl Into<String>) {
51        let s = s.into();
52        if !self.refusals.contains(&s) {
53            self.refusals.push(s);
54        }
55    }
56
57    pub(super) fn fresh_name(&self, base: &str) -> String {
58        let base = var_name(base);
59        let taken = |n: &str| self.vars.iter().any(|v| v.name == n);
60        if !taken(&base) {
61            return base;
62        }
63        (2..)
64            .map(|i| format!("{base}_{i}"))
65            .find(|n| !taken(n))
66            .expect("an unused name")
67    }
68}
69
70/// A Dokploy (or env) name as a native variable name.
71pub(super) fn var_name(s: &str) -> String {
72    let mut out: String = s
73        .to_ascii_lowercase()
74        .chars()
75        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
76        .collect();
77    if !out.starts_with(|c: char| c.is_ascii_lowercase()) {
78        out = format!("v_{out}");
79    }
80    out.truncate(60);
81    out
82}
83
84/// A compose service (or volume) name as an app key / volume name.
85pub fn key_name(s: &str) -> String {
86    let k = crate::compose::sanitize_name(s);
87    let k = k.strip_prefix("isb-").map(String::from).unwrap_or(k);
88    let mut k: String = k.chars().take(30).collect();
89    while k.ends_with('-') {
90        k.pop();
91    }
92    if k.is_empty() || !k.starts_with(|c: char| c.is_ascii_lowercase()) {
93        k = format!("s{k}");
94    }
95    k.chars().take(30).collect()
96}
97
98pub(super) fn lit(s: &str) -> String {
99    s.replace('$', "$$")
100}
101
102pub(super) fn secretish(name: &str) -> bool {
103    let n = name.to_ascii_lowercase();
104    ["pass", "secret", "token", "key", "salt", "private", "jwt"]
105        .iter()
106        .any(|w| n.contains(w))
107}
108
109pub(super) fn yscalar(v: &Y) -> Option<String> {
110    match v {
111        Y::String(s) => Some(s.clone()),
112        Y::Number(n) => Some(n.to_string()),
113        Y::Bool(b) => Some(b.to_string()),
114        Y::Null => Some(String::new()),
115        _ => None,
116    }
117}
118
119/// docker's image reference as isb's.
120pub fn image_ref(image: &str) -> String {
121    let (first, rest) = match image.split_once('/') {
122        Some((f, r)) => (f, Some(r)),
123        None => (image, None),
124    };
125    let is_host =
126        rest.is_some() && (first.contains('.') || first.contains(':') || first == "localhost");
127    match (is_host, rest) {
128        (true, Some(r)) => match first {
129            "docker.io" | "index.docker.io" | "registry-1.docker.io" => format!("docker:{r}"),
130            "ghcr.io" => format!("ghcr:{r}"),
131            "quay.io" => format!("quay:{r}"),
132            _ => format!("oci:{image}"),
133        },
134        _ => format!("docker:{image}"),
135    }
136}
137
138/// Rewrite references to the template's services (compose names) in a
139/// native expression's literal text to `${host:KEY}`: after `//` or `@`,
140/// before `:<port>`, or the whole value when `whole`. Returns the names
141/// rewritten.
142pub(super) fn rewrite_hosts(
143    expr: &str,
144    services: &[(String, String)],
145    whole: bool,
146) -> (String, Vec<String>) {
147    let mut hits = Vec::new();
148    if whole {
149        for (name, key) in services {
150            if expr == lit(name) {
151                return (format!("${{host:{key}}}"), vec![name.clone()]);
152            }
153        }
154    }
155    // Work on literal stretches only: between ${...} references.
156    let mut out = String::new();
157    let mut rest = expr;
158    loop {
159        let (litpart, tail) = match next_ref(rest) {
160            Some((a, b)) => (&rest[..a], Some((a, b))),
161            None => (rest, None),
162        };
163        out.push_str(&rewrite_lit(litpart, services, &mut hits));
164        match tail {
165            Some((a, b)) => {
166                out.push_str(&rest[a..b]);
167                rest = &rest[b..];
168            }
169            None => break,
170        }
171    }
172    (out, hits)
173}
174
175/// The next `${...}` in a native expression (skipping `$$`).
176fn next_ref(s: &str) -> Option<(usize, usize)> {
177    let b = s.as_bytes();
178    let mut i = 0;
179    while i + 1 < b.len() {
180        if b[i] == b'$' && b[i + 1] == b'$' {
181            i += 2;
182        } else if b[i] == b'$' && b[i + 1] == b'{' {
183            let end = s[i..].find('}')? + i + 1;
184            return Some((i, end));
185        } else {
186            i += 1;
187        }
188    }
189    None
190}
191
192fn rewrite_lit(s: &str, services: &[(String, String)], hits: &mut Vec<String>) -> String {
193    let word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-');
194    let mut out = String::new();
195    let mut i = 0;
196    'outer: while i < s.len() {
197        for (name, key) in services {
198            if !s[i..].starts_with(name.as_str()) {
199                continue;
200            }
201            let before = &s[..i];
202            let after = &s[i + name.len()..];
203            let left_ok = before.chars().next_back().is_none_or(|c| !word(c));
204            let right_ok = after.chars().next().is_none_or(|c| !word(c));
205            if !(left_ok && right_ok) {
206                continue;
207            }
208            let url_host = before.ends_with("//") || before.ends_with('@');
209            let host_port =
210                after.starts_with(':') && after[1..].starts_with(|c: char| c.is_ascii_digit());
211            if url_host || host_port {
212                out.push_str(&format!("${{host:{key}}}"));
213                if !hits.contains(name) {
214                    hits.push(name.clone());
215                }
216                i += name.len();
217                continue 'outer;
218            }
219        }
220        let c = s[i..].chars().next().expect("in bounds");
221        out.push(c);
222        i += c.len_utf8();
223    }
224    out
225}
226
227pub(super) fn hostish_key(k: &str) -> bool {
228    let k = k.to_ascii_uppercase();
229    [
230        "HOST", "SERVER", "ADDR", "ENDPOINT", "URL", "URI", "DSN", "BROKER", "NODES", "UPSTREAM",
231        "BACKEND",
232    ]
233    .iter()
234    .any(|w| k.contains(w))
235}
236
237/// The compose keys that cannot be honoured without weakening isolation
238/// or changing what the app is.
239pub(super) const REFUSED_KEYS: &[(&str, &str)] = &[
240    ("privileged", "privileged mode"),
241    ("cap_add", "added capabilities"),
242    ("devices", "host devices"),
243    ("device_cgroup_rules", "device cgroup rules"),
244    ("gpus", "GPUs"),
245    ("pid", "the host's process namespace"),
246    ("ipc", "a shared IPC namespace"),
247    ("userns_mode", "a user-namespace mode"),
248    ("uts", "the host's UTS namespace"),
249    ("cgroup", "a cgroup namespace mode"),
250    ("cgroup_parent", "a cgroup parent"),
251    ("sysctls", "sysctls"),
252    ("runtime", "another container runtime"),
253    ("volumes_from", "volumes_from"),
254    ("extra_hosts", "extra_hosts (host-gateway reaches the host)"),
255    ("dns", "custom DNS servers"),
256    ("dns_search", "custom DNS search domains"),
257    (
258        "build",
259        "an image built from source (use an app with a git source)",
260    ),
261    ("extends", "extends"),
262    ("secrets", "compose secrets"),
263    ("configs", "compose configs"),
264    ("post_start", "lifecycle hooks"),
265    ("pre_stop", "lifecycle hooks"),
266    ("isolation", "an isolation technology"),
267];
268
269/// Keys whose effect isb does not apply; the app still works, so they are
270/// notes.
271pub(super) const NOTED_KEYS: &[(&str, &str)] = &[
272    ("ulimits", "ulimits are not applied (incus defaults)"),
273    ("shm_size", "shm_size is not applied"),
274    (
275        "tmpfs",
276        "tmpfs mounts are not made; the paths are on the root filesystem",
277    ),
278    ("stop_signal", "stop_signal is not applied"),
279    ("stop_grace_period", "stop_grace_period is not applied"),
280    ("read_only", "a read-only root filesystem is not applied"),
281    (
282        "cap_drop",
283        "dropped capabilities are not applied (it runs as an unprivileged incus container)",
284    ),
285    (
286        "security_opt",
287        "security_opt is not applied (it runs as an unprivileged incus container)",
288    ),
289    ("init", "init is not applied"),
290    (
291        "platform",
292        "platform is ignored; the host's architecture is pulled",
293    ),
294    (
295        "hostname",
296        "its hostname is not set; apps reach it as <app>.<stack>",
297    ),
298    ("domainname", "domainname is not set"),
299    ("container_name", "container_name is ignored"),
300    ("mem_reservation", "mem_reservation is not applied"),
301    ("memswap_limit", "memswap_limit is not applied"),
302    ("pids_limit", "pids_limit is not applied"),
303    ("cpu_shares", "cpu_shares is not applied"),
304    ("cpuset", "cpuset is not applied"),
305    ("oom_kill_disable", "oom_kill_disable is not applied"),
306    ("oom_score_adj", "oom_score_adj is not applied"),
307    ("storage_opt", "storage_opt is not applied"),
308    ("blkio_config", "blkio_config is not applied"),
309    ("mac_address", "mac_address is not applied"),
310    ("stop_grace_period", "stop_grace_period is not applied"),
311];
312
313/// Keys that mean nothing here.
314pub(super) const IGNORED_KEYS: &[&str] = &[
315    "image",
316    "exclude_from_hc",
317    "restart",
318    "logging",
319    "tty",
320    "stdin_open",
321    "pull_policy",
322    "expose",
323    "networks",
324    "labels",
325    "environment",
326    "env_file",
327    "volumes",
328    "ports",
329    "command",
330    "entrypoint",
331    "healthcheck",
332    "depends_on",
333    "user",
334    "working_dir",
335    "deploy",
336    "mem_limit",
337    "cpus",
338    "links",
339    "network_mode",
340    "scale",
341    "profiles",
342    "develop",
343    "annotations",
344    "attach",
345];
346
347pub(super) fn ymap(v: &Y) -> Option<&serde_yaml_ng::Mapping> {
348    v.as_mapping()
349}
350
351pub(super) fn yget<'a>(m: &'a serde_yaml_ng::Mapping, k: &str) -> Option<&'a Y> {
352    m.get(Y::String(k.into()))
353}
354
355/// Entries of a list-or-map (`environment`, `labels`) as pairs; a bare
356/// list entry has no value.
357pub(super) fn pairs(v: &Y) -> Vec<(String, Option<String>)> {
358    match v {
359        Y::Sequence(s) => s
360            .iter()
361            .filter_map(yscalar)
362            .map(|e| match e.split_once('=') {
363                Some((k, v)) => (k.to_string(), Some(v.to_string())),
364                None => (e, None),
365            })
366            .collect(),
367        Y::Mapping(m) => m
368            .iter()
369            .filter_map(|(k, v)| {
370                let k = yscalar(k)?;
371                Some((k, if v.is_null() { None } else { yscalar(v) }))
372            })
373            .collect(),
374        _ => vec![],
375    }
376}
377
378pub(super) fn words(v: &Y) -> Option<Vec<String>> {
379    match v {
380        Y::String(s) => crate::flex::split_words(s).ok(),
381        Y::Sequence(s) => s.iter().map(yscalar).collect(),
382        Y::Null => Some(vec![]),
383        _ => None,
384    }
385}
386
387/// The compose file, parsed with its merge keys applied.
388pub(super) fn parse_compose(compose: &str, tx: &mut Tx) -> Option<Y> {
389    let mut doc: Y = match serde_yaml_ng::from_str(compose) {
390        Ok(v) => v,
391        Err(e) => {
392            tx.refuse(format!("docker-compose.yml does not parse: {e}"));
393            return None;
394        }
395    };
396    if let Err(e) = doc.apply_merge() {
397        tx.refuse(format!("docker-compose.yml merge keys: {e}"));
398        return None;
399    }
400    Some(doc)
401}
402
403/// Refuse or note top-level compose keys isb does not use.
404pub(super) fn check_top_level(top: &serde_yaml_ng::Mapping, tx: &mut Tx) {
405    for (k, _) in top {
406        let k = yscalar(k).unwrap_or_default();
407        if !matches!(
408            k.as_str(),
409            "services" | "volumes" | "networks" | "version" | "name"
410        ) && !k.starts_with("x-")
411        {
412            if k == "secrets" || k == "configs" {
413                tx.refuse(format!("top-level compose {k}"));
414            } else {
415                tx.note(format!("top-level compose key {k} is ignored"));
416            }
417        }
418    }
419}
420
421/// The top-level volumes; a volume isb cannot make is refused.
422pub(super) fn declared_volumes(
423    top: &serde_yaml_ng::Mapping,
424    tx: &mut Tx,
425) -> serde_yaml_ng::Mapping {
426    let declared_vols = yget(top, "volumes")
427        .and_then(ymap)
428        .cloned()
429        .unwrap_or_default();
430    for (k, v) in &declared_vols {
431        let k = yscalar(k).unwrap_or_default();
432        if let Some(m) = ymap(v) {
433            if yget(m, "external").and_then(Y::as_bool) == Some(true) {
434                tx.refuse(format!("volume {k} is external (it must exist beforehand)"));
435            }
436            if let Some(d) = yget(m, "driver").and_then(yscalar) {
437                if d != "local" {
438                    tx.refuse(format!("volume {k} uses driver {d}"));
439                }
440            }
441            if yget(m, "driver_opts").is_some() {
442                tx.refuse(format!("volume {k} has driver_opts (a bind or NFS mount)"));
443            }
444        }
445    }
446    declared_vols
447}
448
449/// Each service's app name, and every name a service answers to, longest
450/// first (so `db-replica` is not matched as `db`).
451pub(super) fn service_names(
452    services: &serde_yaml_ng::Mapping,
453    tx: &mut Tx,
454) -> (BTreeMap<String, String>, Vec<(String, String)>) {
455    // Service names, keys, and every name a service answers to.
456    let mut keys: BTreeMap<String, String> = BTreeMap::new();
457    let mut aliases: Vec<(String, String)> = Vec::new();
458    for (name, s) in services {
459        let name = yscalar(name).unwrap_or_default();
460        if s.as_mapping().and_then(|m| yget(m, "profiles")).is_some() {
461            tx.note(format!(
462                "{name} has compose profiles (off by default in docker); it is not deployed"
463            ));
464            continue;
465        }
466        let key = key_name(&name);
467        if keys.values().any(|k| k == &key) {
468            tx.refuse(format!("services {name} and another both become app {key}"));
469        }
470        keys.insert(name.clone(), key.clone());
471        aliases.push((name.clone(), key.clone()));
472        if let Some(m) = ymap(s) {
473            aliases.extend(other_names(m, &name).into_iter().map(|h| (h, key.clone())));
474        }
475    }
476    // Longest names first, so `db-replica` is not matched as `db`.
477    aliases.sort_by_key(|a| std::cmp::Reverse(a.0.len()));
478    (keys, aliases)
479}
480
481/// The other names a service answers to: its hostname, container name and
482/// network aliases.
483fn other_names(m: &serde_yaml_ng::Mapping, name: &str) -> Vec<String> {
484    let mut out = Vec::new();
485    for k in ["hostname", "container_name"] {
486        if let Some(h) = yget(m, k).and_then(yscalar) {
487            if h != name && !h.contains('$') {
488                out.push(h);
489            }
490        }
491    }
492    if let Some(Y::Mapping(nets)) = yget(m, "networks") {
493        for (_, n) in nets {
494            if let Some(Y::Sequence(al)) = ymap(n).and_then(|n| yget(n, "aliases")) {
495                out.extend(al.iter().filter_map(yscalar));
496            }
497        }
498    }
499    out
500}
501
502/// A translation's outcome: the template when nothing was refused and it
503/// validates, and the report.
504pub(super) fn finish(t: Option<Template>, mut tx: Tx) -> (Option<Template>, Report) {
505    let t = match t {
506        Some(t) if tx.refusals.is_empty() => match t.validate() {
507            Ok(()) => Some(t),
508            Err(e) => {
509                tx.refuse(format!("the translation does not validate: {e}"));
510                None
511            }
512        },
513        _ => None,
514    };
515    let status = if !tx.refusals.is_empty() {
516        Status::Refused
517    } else if tx.notes.is_empty() {
518        Status::Clean
519    } else {
520        Status::Notes
521    };
522    (
523        t,
524        Report {
525            status,
526            notes: tx.notes,
527            refusals: tx.refusals,
528        },
529    )
530}