Skip to main content

isb_apps/template/
coolify.rs

1//! Coolify's template format, translated into isb's.
2//!
3//! A Coolify template is one Docker Compose file, `templates/compose/<id>.yaml`
4//! in `coollabsio/coolify`, with its metadata in comments at the top
5//! (`# documentation:`, `# slogan:`, `# category:`, `# tags:`, `# logo:`,
6//! `# port:`) and Coolify's "magic" environment variables in the compose
7//! (see `magic`): `SERVICE_URL_*` / `SERVICE_FQDN_*` give a service a
8//! domain, `SERVICE_PASSWORD_*` and friends are generated once, and
9//! `${VAR:-default}` is an input with a default. A `volumes:` entry can carry
10//! its file's `content:`.
11//!
12//! The translation is as strict as Dokploy's (and shares its compose rules,
13//! [`super::dokploy`]): each compose service becomes one app, and anything
14//! isb's model cannot express or that would weaken isolation (privileged,
15//! capabilities, devices, host namespaces, host paths, the container
16//! runtime's socket, one volume shared by several services, one-shot jobs) is
17//! refused with a reason. What is mapped with a change of meaning is listed
18//! in the report's notes; nothing is dropped silently.
19
20mod magic;
21mod service;
22#[cfg(test)]
23mod tests;
24#[cfg(test)]
25mod tests_more;
26
27use std::collections::BTreeMap;
28
29use serde::Serialize;
30
31use self::magic::Cx;
32use super::Template;
33use super::shared::{
34    Report, Tx, check_top_level, declared_volumes, finish, key_name, parse_compose, service_names,
35    yget, ymap, yscalar,
36};
37
38pub use self::magic::{Gen, Magic, classify};
39
40/// A template's metadata, from the comments at the top of its file.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
42pub struct Meta {
43    /// The file's name without `.yaml`.
44    pub id: String,
45    /// The id, titled (`uptime-kuma` is `Uptime Kuma`).
46    pub name: String,
47    /// The slogan.
48    pub description: String,
49    pub category: String,
50    pub tags: Vec<String>,
51    /// The logo's https URL, when the catalog has a base to resolve it
52    /// against.
53    pub logo: Option<String>,
54    /// The documentation link.
55    pub docs: Option<String>,
56    /// The port the template's main service listens on.
57    pub port: Option<u16>,
58    /// `# ignore: true`: Coolify does not offer it.
59    pub ignore: bool,
60}
61
62/// The `# key: value` comments at the top of a template, in order.
63pub fn header(text: &str) -> Vec<(String, String)> {
64    let mut out = Vec::new();
65    for line in text.lines() {
66        let l = line.trim();
67        if l.is_empty() {
68            continue;
69        }
70        let Some(c) = l.strip_prefix('#') else { break };
71        if let Some((k, v)) = c.split_once(':') {
72            let k = k.trim().to_ascii_lowercase();
73            if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
74                out.push((k, v.trim().to_string()));
75            }
76        }
77    }
78    out
79}
80
81/// `uptime-kuma-with-mysql` as `Uptime Kuma With Mysql`.
82pub fn titled(id: &str) -> String {
83    id.split(['-', '_', '.'])
84        .filter(|w| !w.is_empty())
85        .map(|w| {
86            let mut c = w.chars();
87            match c.next() {
88                Some(f) => f.to_uppercase().chain(c).collect::<String>(),
89                None => String::new(),
90            }
91        })
92        .collect::<Vec<_>>()
93        .join(" ")
94}
95
96/// A logo path from the header, safe to put in a URL.
97fn logo_path_ok(p: &str) -> bool {
98    !p.is_empty()
99        && p.len() <= 200
100        && !p.starts_with(['/', '.'])
101        && !p.contains("..")
102        && p.chars()
103            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
104}
105
106/// A template's metadata. `logo_base` is where the repository's `public/`
107/// directory is served from (a catalog URL), if anywhere.
108pub fn meta(id: &str, text: &str, logo_base: Option<&str>) -> Meta {
109    let h = header(text);
110    let get = |k: &str| h.iter().find(|(a, _)| a == k).map(|(_, v)| v.as_str());
111    let mut tags: Vec<String> = vec![];
112    for t in get("category")
113        .into_iter()
114        .chain(get("tags").unwrap_or("").split(','))
115    {
116        let t = t.trim().to_ascii_lowercase();
117        if !t.is_empty() && !tags.contains(&t) {
118            tags.push(t);
119        }
120    }
121    Meta {
122        id: id.to_string(),
123        name: titled(id),
124        description: get("slogan")
125            .unwrap_or("")
126            .trim_matches(['"', '\''])
127            .trim()
128            .to_string(),
129        category: get("category").unwrap_or("").to_string(),
130        tags,
131        logo: get("logo")
132            .filter(|l| logo_path_ok(l))
133            .zip(logo_base)
134            .map(|(l, b)| format!("{}/public/{l}", b.trim_end_matches('/'))),
135        docs: get("documentation")
136            .filter(|d| d.starts_with("https://") || d.starts_with("http://"))
137            .map(String::from),
138        port: get("port").and_then(|p| p.parse().ok()),
139        ignore: get("ignore").is_some_and(|v| v.eq_ignore_ascii_case("true")),
140    }
141}
142
143/// Translate one Coolify template. `None` with refusals when it cannot run
144/// on isb.
145pub fn translate(meta: &Meta, compose: &str) -> (Option<Template>, Report) {
146    let mut tx = Tx {
147        notes: vec![],
148        refusals: vec![],
149        vars: vec![],
150        names: BTreeMap::new(),
151    };
152    let t = translate_inner(meta, compose, &mut tx);
153    finish(t, tx)
154}
155
156fn translate_inner(meta: &Meta, compose: &str, tx: &mut Tx) -> Option<Template> {
157    let doc = parse_compose(compose, tx)?;
158    let Some(top) = ymap(&doc) else {
159        tx.refuse("the compose file is not a mapping");
160        return None;
161    };
162    check_top_level(top, tx);
163    declared_volumes(top, tx);
164    let Some(services) = yget(top, "services").and_then(ymap) else {
165        tx.refuse("the compose file has no services");
166        return None;
167    };
168    let (keys, aliases) = service_names(services, tx);
169    let cx = Cx::new(&doc, services, (&keys, &aliases), meta.port, tx);
170    let mut acc = service::Acc::default();
171    let mut apps = Vec::new();
172    for (name, s) in services {
173        let name = yscalar(name).unwrap_or_default();
174        let Some(key) = keys.get(&name).cloned() else {
175            continue;
176        };
177        let Some(m) = ymap(s) else {
178            tx.refuse(format!("service {name} is not a mapping"));
179            continue;
180        };
181        if let Some(app) = service::translate(&cx, &mut acc, &name, key, m, tx) {
182            apps.push(app);
183        }
184    }
185    for (v, users) in &acc.vol_users {
186        if users.len() > 1 {
187            tx.refuse(format!(
188                "volume {v} is shared by {} (an app's volumes are its own)",
189                users.iter().cloned().collect::<Vec<_>>().join(", ")
190            ));
191        }
192    }
193    if apps.is_empty() {
194        tx.refuse("no service to deploy");
195        return None;
196    }
197    cx.finish(tx);
198    let main = cx
199        .decls
200        .iter()
201        .find_map(|d| keys.get(&d.service))
202        .cloned()
203        .unwrap_or_else(|| apps[0].name.clone());
204    Some(Template {
205        id: key_name(&meta.id),
206        name: meta.name.clone(),
207        description: meta.description.clone(),
208        version: String::new(),
209        logo: meta.logo.clone(),
210        tags: meta.tags.clone(),
211        links: meta
212            .docs
213            .iter()
214            .map(|d| ("docs".to_string(), d.clone()))
215            .collect(),
216        variables: tx.vars.clone(),
217        apps,
218        main: Some(main),
219        notes: vec![],
220    })
221}