Skip to main content

isb_apps/template/coolify/
magic.rs

1//! Coolify's "magic" variables and compose interpolation, as isb template
2//! variables and expressions.
3//!
4//! `SERVICE_URL_<NAME>[_<PORT>]` and `SERVICE_FQDN_<NAME>[_<PORT>]` give a
5//! service a domain (the service whose `environment:` declares the name; a
6//! name nothing declares falls back to the service called that);
7//! `SERVICE_PASSWORD_*`, `SERVICE_USER_*`, `SERVICE_BASE64_*`,
8//! `SERVICE_REALBASE64_*`, `SERVICE_HEX_*` and the Supabase JWTs are values
9//! generated once per deployment and shared by every use of the same name;
10//! `SERVICE_NAME_<SERVICE>` is a service's name. Everything else in
11//! `${...}` / `$NAME` is an ordinary variable with an optional default.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use serde_yaml_ng::Value as Y;
16
17use super::super::shared::{Tx, pairs, secretish, var_name, yget, yscalar};
18use super::super::{JwtSpec, VarKind, Variable};
19
20/// How a generated value is made.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Gen {
23    /// Letters and digits; `symbols` when Coolify's has punctuation too.
24    Password {
25        len: u32,
26        symbols: bool,
27    },
28    User,
29    /// `BASE64`: despite the name, letters and digits.
30    Alnum(u32),
31    /// `REALBASE64`: base64 of this many random bytes.
32    RealBase64(u32),
33    /// `HEX`: this many hexadecimal characters.
34    Hex(u32),
35    /// A Supabase JWT with this role, signed with `SERVICE_PASSWORD_JWT`.
36    Jwt(&'static str),
37}
38
39/// What a `SERVICE_*` name means.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Magic {
42    Url {
43        name: String,
44        port: Option<u16>,
45    },
46    Fqdn {
47        name: String,
48        port: Option<u16>,
49    },
50    /// A service's name, as `SERVICE_NAME_<SERVICE>` spells it.
51    Name(String),
52    Gen(Gen),
53}
54
55/// A trailing `_<port>` on a URL or FQDN name.
56fn split_port(s: &str) -> (String, Option<u16>) {
57    if let Some((name, port)) = s.rsplit_once('_') {
58        if let Ok(p) = port.parse::<u16>() {
59            if p > 0 && !name.is_empty() {
60                return (name.to_string(), Some(p));
61            }
62        }
63    }
64    (s.to_string(), None)
65}
66
67/// `32_ID` / `64_ID` / `128_ID` as (size, ID); `ID` alone has the default
68/// size.
69fn sized<'a>(tail: &'a str, sizes: &[u32], default: u32) -> (u32, &'a str) {
70    for n in sizes {
71        if let Some(rest) = tail.strip_prefix(&format!("{n}_")) {
72            if !rest.is_empty() {
73                return (*n, rest);
74            }
75        }
76    }
77    (default, tail)
78}
79
80/// The meaning of a name such as `SERVICE_PASSWORD_64_UMAMI`, or `None`
81/// for an ordinary variable.
82pub fn classify(token: &str) -> Option<Magic> {
83    let rest = token.strip_prefix("SERVICE_")?;
84    if !rest
85        .chars()
86        .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
87    {
88        return None;
89    }
90    let (kind, tail) = rest.split_once('_')?;
91    if tail.is_empty() {
92        return None;
93    }
94    Some(match kind {
95        "URL" => {
96            let (name, port) = split_port(tail);
97            Magic::Url { name, port }
98        }
99        "FQDN" => {
100            let (name, port) = split_port(tail);
101            Magic::Fqdn { name, port }
102        }
103        "NAME" => Magic::Name(tail.to_string()),
104        "USER" | "LOWERCASEUSER" => Magic::Gen(Gen::User),
105        "PASSWORD" | "PASSWORDWITHSYMBOLS" => Magic::Gen(Gen::Password {
106            len: sized(tail, &[64], 32).0,
107            symbols: kind == "PASSWORDWITHSYMBOLS",
108        }),
109        "BASE64" => Magic::Gen(Gen::Alnum(sized(tail, &[32, 64, 128], 32).0)),
110        "REALBASE64" => Magic::Gen(Gen::RealBase64(sized(tail, &[32, 64, 128], 32).0)),
111        "HEX" => {
112            let (n, id) = sized(tail, &[32, 64, 128], 0);
113            if n == 0 || id.is_empty() {
114                return None;
115            }
116            Magic::Gen(Gen::Hex(n))
117        }
118        "SUPABASEANON" => Magic::Gen(Gen::Jwt("anon")),
119        "SUPABASESERVICE" => Magic::Gen(Gen::Jwt("service_role")),
120        _ => return None,
121    })
122}
123
124/// A service name as `SERVICE_NAME_*` and URL names spell it.
125pub fn normalized(service: &str) -> String {
126    service
127        .to_ascii_uppercase()
128        .chars()
129        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
130        .collect()
131}
132
133/// One service's claim on a domain: its `environment:` names
134/// `SERVICE_URL_<name>[_<port>]` (or FQDN).
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct Decl {
137    /// The compose service.
138    pub service: String,
139    pub name: String,
140    pub port: Option<u16>,
141    /// A path the value gives (`SERVICE_URL_API=/v1`).
142    pub path: Option<String>,
143}
144
145/// Every `SERVICE_*` name in a YAML value, keys and values, and every
146/// `${NAME` / `$NAME` outside `content:` blocks.
147pub fn scan(v: &Y, tokens: &mut BTreeSet<String>, refs: &mut BTreeSet<String>) {
148    match v {
149        Y::String(s) => scan_text(s, tokens, refs),
150        Y::Sequence(s) => s.iter().for_each(|x| scan(x, tokens, refs)),
151        Y::Mapping(m) => {
152            for (k, x) in m {
153                if let Some(k) = k.as_str() {
154                    scan_text(k, tokens, refs);
155                    if k == "content" {
156                        // Only magic names are read in file contents.
157                        if let Some(s) = x.as_str() {
158                            scan_text(s, tokens, &mut BTreeSet::new());
159                        }
160                        continue;
161                    }
162                }
163                scan(x, tokens, refs);
164            }
165        }
166        _ => {}
167    }
168}
169
170fn name_at(s: &str, from: usize) -> &str {
171    let rest = &s[from..];
172    let end = rest
173        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
174        .unwrap_or(rest.len());
175    &rest[..end]
176}
177
178fn scan_text(s: &str, tokens: &mut BTreeSet<String>, refs: &mut BTreeSet<String>) {
179    let b = s.as_bytes();
180    let mut i = 0;
181    while i < b.len() {
182        // Magic names are found anywhere a word starts.
183        let word_start = i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_');
184        if word_start && s[i..].starts_with("SERVICE_") {
185            let t = name_at(s, i);
186            if classify(t).is_some() {
187                tokens.insert(t.to_string());
188            }
189            i += t.len().max(1);
190            continue;
191        }
192        if b[i] == b'$' && b.get(i + 1) == Some(&b'$') {
193            i += 2;
194            continue;
195        }
196        if b[i] == b'$' {
197            let from = if b.get(i + 1) == Some(&b'{') {
198                i + 2
199            } else {
200                i + 1
201            };
202            let n = name_at(s, from);
203            if n.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
204                refs.insert(n.to_string());
205            }
206        }
207        i += 1;
208        while !s.is_char_boundary(i) {
209            i += 1;
210        }
211    }
212}
213
214/// The domains each service's `environment:` declares, in compose order.
215pub fn declarations(
216    services: &serde_yaml_ng::Mapping,
217    skipped: &dyn Fn(&str) -> bool,
218) -> Vec<Decl> {
219    let mut out = Vec::new();
220    for (name, s) in services {
221        let Some(name) = yscalar(name) else { continue };
222        if skipped(&name) {
223            continue;
224        }
225        let Some(env) = s.as_mapping().and_then(|m| yget(m, "environment")) else {
226            continue;
227        };
228        for (k, v) in pairs(env) {
229            let (name_, port) = match classify(&k) {
230                Some(Magic::Url { name, port } | Magic::Fqdn { name, port }) => (name, port),
231                _ => continue,
232            };
233            let v = v.unwrap_or_default();
234            // A value that is another expression is a pass-through, not a
235            // claim.
236            if !(v.is_empty() || v.starts_with('/')) {
237                continue;
238            }
239            let path = (v.len() > 1).then_some(v);
240            out.push(Decl {
241                service: name.clone(),
242                name: name_,
243                port,
244                path,
245            });
246        }
247    }
248    out
249}
250
251/// How a piece of text is read: compose interpolation everywhere, but file
252/// contents expand only the names Coolify knows.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum Mode {
255    Compose,
256    Content,
257}
258
259/// What interpolation needs to know about the template.
260pub struct Cx<'a> {
261    /// Compose service name to app key.
262    pub keys: &'a BTreeMap<String, String>,
263    /// Every name a service answers to, for rewriting host names.
264    pub aliases: &'a [(String, String)],
265    /// The template's `# port:`.
266    pub port_hint: Option<u16>,
267    /// Normalized service name to compose service name.
268    by_norm: BTreeMap<String, String>,
269    pub decls: Vec<Decl>,
270    /// Domain name to the native variable holding its host.
271    hosts: BTreeMap<String, String>,
272    /// Variables the compose file interpolates outside file contents.
273    known: BTreeSet<String>,
274}
275
276impl<'a> Cx<'a> {
277    /// Read the declarations and references of the compose document, and
278    /// make a domain variable for every domain name.
279    pub fn new(
280        doc: &Y,
281        services: &serde_yaml_ng::Mapping,
282        names: (&'a BTreeMap<String, String>, &'a [(String, String)]),
283        port_hint: Option<u16>,
284        tx: &mut Tx,
285    ) -> Cx<'a> {
286        let (keys, aliases) = names;
287        let by_norm: BTreeMap<String, String> =
288            keys.keys().map(|s| (normalized(s), s.clone())).collect();
289        let mut decls = declarations(services, &|s| !keys.contains_key(s));
290        let (mut tokens, mut known) = (BTreeSet::new(), BTreeSet::new());
291        scan(doc, &mut tokens, &mut known);
292        known.retain(|n| classify(n).is_none());
293        for t in tokens {
294            let (name, port) = match classify(&t) {
295                Some(Magic::Url { name, port } | Magic::Fqdn { name, port }) => (name, port),
296                _ => continue,
297            };
298            if decls.iter().any(|d| d.name == name) {
299                continue;
300            }
301            // Nothing declares it: the service of that name gets it.
302            let owner = by_norm.get(&name).map(|s| (s, port)).or_else(|| {
303                by_norm
304                    .get(&format!("{name}_{}", port.unwrap_or(0)))
305                    .map(|s| (s, None))
306            });
307            if let Some((svc, port)) = owner {
308                decls.push(Decl {
309                    service: svc.clone(),
310                    name,
311                    port,
312                    path: None,
313                });
314            }
315        }
316        let mut hosts = BTreeMap::new();
317        for d in &decls {
318            if !hosts.contains_key(&d.name) {
319                let n = tx.fresh_name(&format!("domain_{}", d.name));
320                tx.vars.push(Variable {
321                    name: n.clone(),
322                    kind: VarKind::Domain,
323                    ..Default::default()
324                });
325                hosts.insert(d.name.clone(), n);
326            }
327        }
328        Cx {
329            keys,
330            aliases,
331            port_hint,
332            by_norm,
333            decls,
334            hosts,
335            known,
336        }
337    }
338
339    /// The native variable holding a domain name's host.
340    pub fn host_var(&self, name: &str) -> Option<&str> {
341        self.hosts.get(name).map(String::as_str)
342    }
343
344    /// The path a declaration of this exact name gives.
345    fn path_of(&self, name: &str, port: Option<u16>) -> Option<&str> {
346        self.decls
347            .iter()
348            .find(|d| d.name == name && d.port == port && d.path.is_some())
349            .and_then(|d| d.path.as_deref())
350    }
351
352    /// An ordinary variable's native name (made on first use).
353    pub fn var(&self, name: &str, tx: &mut Tx) -> String {
354        if let Some(n) = tx.names.get(name) {
355            return n.clone();
356        }
357        let n = tx.fresh_name(name);
358        tx.names.insert(name.to_string(), n.clone());
359        tx.vars.push(Variable {
360            name: n.clone(),
361            ..Default::default()
362        });
363        n
364    }
365
366    /// A generated value's native variable (made on first use).
367    fn generated(&self, token: &str, g: &Gen, tx: &mut Tx) -> String {
368        if let Some(n) = tx.names.get(token) {
369            return n.clone();
370        }
371        let secret_var = match g {
372            Gen::Jwt(_) => Some(self.generated(
373                "SERVICE_PASSWORD_JWT",
374                &Gen::Password {
375                    len: 32,
376                    symbols: false,
377                },
378                tx,
379            )),
380            _ => None,
381        };
382        let n = tx.fresh_name(&var_name(token.trim_start_matches("SERVICE_")));
383        let mut v = Variable {
384            name: n.clone(),
385            ..Default::default()
386        };
387        match g {
388            Gen::Password { len, symbols } => {
389                v.kind = VarKind::Password;
390                v.length = Some(*len);
391                if *symbols {
392                    tx.note(format!("{token} has no symbols (letters and digits only)"));
393                }
394            }
395            Gen::User => {
396                v.kind = VarKind::Username;
397                v.length = Some(16);
398            }
399            Gen::Alnum(len) => {
400                v.kind = VarKind::Password;
401                v.length = Some(*len);
402            }
403            Gen::RealBase64(bytes) => {
404                v.kind = VarKind::Base64;
405                v.bytes = Some(*bytes);
406            }
407            Gen::Hex(chars) => {
408                v.kind = VarKind::Hex;
409                v.bytes = Some(chars / 2);
410                v.secret = Some(true);
411            }
412            Gen::Jwt(role) => {
413                v.kind = VarKind::Jwt;
414                v.jwt = Some(JwtSpec {
415                    secret: secret_var.unwrap_or_default(),
416                    payload: Some(format!("{{\"role\":\"{role}\",\"iss\":\"supabase\"}}")),
417                });
418            }
419        }
420        tx.names.insert(token.to_string(), n.clone());
421        tx.vars.push(v);
422        n
423    }
424
425    /// A magic name's value as a native expression.
426    pub fn magic(&self, token: &str, m: &Magic, tx: &mut Tx) -> String {
427        match m {
428            Magic::Url { name, port } | Magic::Fqdn { name, port } => {
429                let Some(h) = self.host_var(name) else {
430                    tx.refuse(format!("{token}: no service declares or is named {name}"));
431                    return String::new();
432                };
433                if matches!(m, Magic::Fqdn { .. }) {
434                    format!("${{{h}}}")
435                } else {
436                    let path = self.path_of(name, *port).unwrap_or("");
437                    format!("https://${{{h}}}{}", path.replace('$', "$$"))
438                }
439            }
440            Magic::Name(n) => match self.by_norm.get(n).and_then(|s| self.keys.get(s)) {
441                Some(k) => format!("${{host:{k}}}"),
442                None => {
443                    tx.refuse(format!("{token}: no service named {n}"));
444                    String::new()
445                }
446            },
447            Magic::Gen(g) => format!("${{{}}}", self.generated(token, g, tx)),
448        }
449    }
450
451    /// A variable's default, the first one seen wins.
452    fn set_default(&self, native: &str, d: String, tx: &mut Tx) {
453        let Some(v) = tx.vars.iter_mut().find(|v| v.name == native) else {
454            return;
455        };
456        match &v.default {
457            None => v.default = Some(d),
458            Some(e) if *e != d => {
459                let msg = format!(
460                    "{native} has different defaults in different places; the first is used"
461                );
462                tx.note(msg);
463            }
464            Some(_) => {}
465        }
466    }
467
468    /// `${name<op>}` as a native expression.
469    fn reference(&self, name: &str, op: &str, tx: &mut Tx) -> String {
470        if let Some(m) = classify(name) {
471            return self.magic(name, &m, tx);
472        }
473        let native = self.var(name, tx);
474        if let Some(d) = op.strip_prefix(":-").or_else(|| op.strip_prefix('-')) {
475            let d = self.expr(d, Mode::Compose, tx);
476            self.set_default(&native, d, tx);
477        } else if op.starts_with(":?") || op.starts_with('?') {
478            if let Some(v) = tx.vars.iter_mut().find(|v| v.name == native) {
479                v.required = Some(true);
480            }
481        } else if !op.is_empty() {
482            tx.refuse(format!("${{{name}{op}}}: conditional interpolation"));
483        }
484        format!("${{{native}}}")
485    }
486
487    /// Whether `${name}` is expanded in file contents.
488    fn expands_in_content(&self, name: &str) -> bool {
489        classify(name).is_some() || self.known.contains(name)
490    }
491
492    /// Interpolate a compose value (or file content) into a native
493    /// expression: references become variables, the rest is literal.
494    pub fn expr(&self, s: &str, mode: Mode, tx: &mut Tx) -> String {
495        let mut out = String::new();
496        let mut i = 0;
497        while i < s.len() {
498            let Some(off) = s[i..].find('$') else {
499                out.push_str(&s[i..]);
500                break;
501            };
502            out.push_str(&s[i..i + off]);
503            i += off;
504            i = self.dollar(s, i, mode, &mut out, tx);
505        }
506        out
507    }
508
509    /// The `$...` at `s[i..]`, written to `out`; the index after it.
510    fn dollar(&self, s: &str, i: usize, mode: Mode, out: &mut String, tx: &mut Tx) -> usize {
511        let rest = &s[i + 1..];
512        if rest.starts_with('$') {
513            out.push_str(if mode == Mode::Compose { "$$" } else { "$$$$" });
514            return i + 2;
515        }
516        if rest.starts_with('{') {
517            let Some(end) = matching_brace(s, i + 1) else {
518                if mode == Mode::Compose {
519                    tx.refuse(format!("unterminated ${{ in {s:?}"));
520                }
521                out.push_str("$$");
522                return i + 1;
523            };
524            let inner = &s[i + 2..end];
525            let name = name_at(inner, 0);
526            let valid = name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_');
527            if valid && (mode == Mode::Compose || self.expands_in_content(name)) {
528                out.push_str(&self.reference(name, &inner[name.len()..], tx));
529            } else if mode == Mode::Compose {
530                tx.refuse(format!("${{{inner}}} is not a variable"));
531            } else {
532                out.push_str(&s[i..=end].replace('$', "$$"));
533            }
534            return end + 1;
535        }
536        let name = name_at(s, i + 1);
537        let valid = name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_');
538        if valid && (mode == Mode::Compose || classify(name).is_some()) {
539            out.push_str(&self.reference(name, "", tx));
540            return i + 1 + name.len();
541        }
542        out.push_str("$$");
543        i + 1
544    }
545
546    /// A bare environment entry's value: the magic value, or the variable
547    /// of that name.
548    pub fn bare(&self, name: &str, tx: &mut Tx) -> String {
549        self.reference(name, "", tx)
550    }
551
552    /// The inputs that are secrets: a name that says so, with a literal
553    /// default.
554    pub fn finish(&self, tx: &mut Tx) {
555        let names: BTreeMap<String, String> = tx
556            .names
557            .iter()
558            .map(|(orig, native)| (native.clone(), orig.clone()))
559            .collect();
560        for v in &mut tx.vars {
561            if v.kind != VarKind::String {
562                continue;
563            }
564            if v.default.is_none() && v.required != Some(true) {
565                v.default = Some(String::new());
566            }
567            if v.default.is_some() {
568                v.required = None;
569            }
570            let literal = v
571                .default
572                .as_deref()
573                .is_some_and(|d| !d.is_empty() && !d.contains("${"));
574            if literal && names.get(&v.name).is_some_and(|o| secretish(o)) {
575                v.secret = Some(true);
576            }
577        }
578    }
579}
580
581/// The `}` closing the `{` at `open` (defaults may nest `${...}`).
582fn matching_brace(s: &str, open: usize) -> Option<usize> {
583    let mut depth = 0;
584    for (j, c) in s[open..].char_indices() {
585        match c {
586            '{' => depth += 1,
587            '}' => {
588                depth -= 1;
589                if depth == 0 {
590                    return Some(open + j);
591                }
592            }
593            _ => {}
594        }
595    }
596    None
597}