Skip to main content

isb_apps/template/
mod.rs

1//! Templates: one-click apps.
2//!
3//! A template is metadata, variables and a set of apps. Deploying one into
4//! a project environment fills the variables (from the caller, a generator
5//! or a default), renders each app template into an ordinary
6//! [`crate::app::AppSpec`], and creates the apps; each then deploys like any
7//! app, so the app pages, deployments, rollbacks and env editor work for
8//! them. A template's app is named `<instance>-<key>` (the main app just
9//! `<instance>`), and apps reach each other as `<app>.<project>-<env>`,
10//! written `${host:KEY}` in a template.
11//!
12//! Secret variables (generated passwords and keys) become org secrets
13//! (`tpl.<instance>.<var>`); a value built from one (a connection string)
14//! becomes its own secret, and so does every file. Stored app definitions
15//! hold only references.
16//!
17//! [`catalog`] holds the built-in templates and the catalogs a platform
18//! admin adds; [`dokploy`] translates Dokploy's format into this one.
19
20pub mod catalog;
21pub mod coolify;
22pub mod dokploy;
23pub mod generate;
24mod planning;
25mod shared;
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::net::IpAddr;
29
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32
33use crate::app::{AppSpec, Resources};
34use crate::error::{Error, Result};
35use crate::org::OrgId;
36
37/// A template, as written in YAML (docs/guides/templates.md).
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct Template {
41    /// `[a-z0-9-]`, unique in its catalog.
42    pub id: String,
43    pub name: String,
44    #[serde(default, skip_serializing_if = "String::is_empty")]
45    pub description: String,
46    /// The template's own version (usually the app's).
47    #[serde(default, skip_serializing_if = "String::is_empty")]
48    pub version: String,
49    /// A logo URL.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub logo: Option<String>,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub tags: Vec<String>,
54    /// `website`, `docs`, `source`, ... to URLs.
55    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
56    pub links: BTreeMap<String, String>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub variables: Vec<Variable>,
59    pub apps: Vec<AppTemplate>,
60    /// The app named after the instance (default: the only app).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub main: Option<String>,
63    /// What to know after deploying (first-run steps, default logins).
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub notes: Vec<String>,
66}
67
68/// What a variable holds, and how it gets a value nobody gave.
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum VarKind {
72    /// Text the deployer gives (or `default`).
73    #[default]
74    String,
75    Email,
76    Url,
77    Int,
78    /// A hostname for the ingress; empty means a generated one (`host: auto`).
79    Domain,
80    /// Generated: `length` letters and digits (default 32).
81    Password,
82    /// Generated: `bytes` random bytes, base64 (default 32).
83    Base64,
84    /// Generated: `bytes` random bytes, hex (default 32).
85    Hex,
86    /// Generated: a random UUID.
87    Uuid,
88    /// Generated: a free host TCP port.
89    Port,
90    /// Generated: `length` lowercase letters (default 8).
91    Username,
92    /// Generated: now (or `at`), in seconds (or `unit: ms`).
93    Timestamp,
94    /// Generated: an HS256 JWT signed with variable `jwt.secret`.
95    Jwt,
96}
97
98impl VarKind {
99    fn generated(self) -> bool {
100        !matches!(
101            self,
102            VarKind::String | VarKind::Email | VarKind::Url | VarKind::Int | VarKind::Domain
103        )
104    }
105
106    fn secret_by_default(self) -> bool {
107        matches!(
108            self,
109            VarKind::Password | VarKind::Base64 | VarKind::Hex | VarKind::Jwt
110        )
111    }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct JwtSpec {
117    /// The variable holding the signing secret.
118    pub secret: String,
119    /// The claims: an expression that renders to a JSON object. Default
120    /// `{"iss": "isb", "iat": now, "exp": now + 10 years}`.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub payload: Option<String>,
123}
124
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct Variable {
128    /// `[a-z][a-z0-9_]*`.
129    pub name: String,
130    #[serde(rename = "type", default)]
131    pub kind: VarKind,
132    #[serde(default, skip_serializing_if = "String::is_empty")]
133    pub label: String,
134    #[serde(default, skip_serializing_if = "String::is_empty")]
135    pub description: String,
136    /// Used when the deployer gives nothing; may use `${...}`.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub default: Option<String>,
139    /// A computed value (`${...}` over other variables); not an input.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub value: Option<String>,
142    /// Must the deployer give it? Default: a text variable without a
143    /// default.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub required: Option<bool>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub length: Option<u32>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub bytes: Option<u32>,
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub choices: Vec<String>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub min_length: Option<u32>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub max_length: Option<u32>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub min: Option<i64>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub max: Option<i64>,
160    /// Stored as an org secret. Default: passwords, keys and JWTs.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub secret: Option<bool>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub jwt: Option<JwtSpec>,
165    /// Timestamp: a date instead of now (`2030-01-01T00:00:00Z`).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub at: Option<String>,
168    /// Timestamp: `s` (default) or `ms`.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub unit: Option<String>,
171}
172
173impl Variable {
174    pub fn is_input(&self) -> bool {
175        self.value.is_none()
176    }
177
178    fn required(&self) -> bool {
179        self.required.unwrap_or(
180            self.value.is_none() && self.default.is_none() && !self.kind.generated() && {
181                self.kind != VarKind::Domain
182            },
183        )
184    }
185
186    fn declared_secret(&self) -> bool {
187        self.secret.unwrap_or(self.kind.secret_by_default())
188    }
189}
190
191/// A file an app gets, rendered and stored as an org secret.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct FileTemplate {
195    pub path: String,
196    pub content: String,
197    /// Octal; default `0444` (config files are read by whatever user the
198    /// image runs as).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub mode: Option<String>,
201}
202
203/// One app of a template: the fields of an [`AppSpec`] with an image
204/// source, as strings that may use `${var}` and `${host:KEY}`.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct AppTemplate {
208    /// The key: `[a-z0-9-]`; the app is `<instance>-<key>`.
209    pub name: String,
210    /// An isb image reference (`docker:louislam/uptime-kuma:1`).
211    pub image: String,
212    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
213    pub env: BTreeMap<String, String>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub port: Option<u16>,
216    /// `{host, path?, port?, https?, strip_prefix?, ...}` as the ingress
217    /// takes them.
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub domains: Vec<serde_json::Map<String, Value>>,
220    /// Named volumes, `NAME:/path[:ro]`.
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub volumes: Vec<String>,
223    /// Published host ports (compose syntax).
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub ports: Vec<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub replicas: Option<u32>,
228    /// The whole command line (replaces the image's entrypoint too, as
229    /// isb's `command` does): argv, or a line split like a shell would.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub command: Option<Value>,
232    /// Arguments after the image's own entrypoint (docker's `command`):
233    /// the entrypoint is read from the image when the template is deployed.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub args: Option<Value>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub healthcheck: Option<Value>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub resources: Option<Resources>,
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub files: Vec<FileTemplate>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub user: Option<String>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub working_dir: Option<String>,
246    /// Apps deployed (and converged) before this one.
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub depends_on: Vec<String>,
249}
250
251// --- expressions ----------------------------------------------------------
252
253/// A piece of a template string.
254#[derive(Debug, Clone, PartialEq, Eq)]
255enum Part {
256    Lit(String),
257    Var(String),
258    Host(String),
259}
260
261/// `${name}` is a variable, `${host:KEY}` an app's service name, `$$` a
262/// literal `$`; any other `$` is literal. Other `${...}` forms are errors.
263fn parse_expr(s: &str) -> std::result::Result<Vec<Part>, String> {
264    let mut out = Vec::new();
265    let mut lit = String::new();
266    let b = s.as_bytes();
267    let mut i = 0;
268    while i < b.len() {
269        if b[i] == b'$' && b.get(i + 1) == Some(&b'$') {
270            lit.push('$');
271            i += 2;
272        } else if b[i] == b'$' && b.get(i + 1) == Some(&b'{') {
273            let end = s[i + 2..]
274                .find('}')
275                .ok_or_else(|| format!("unterminated ${{ in {s:?}"))?;
276            let inner = &s[i + 2..i + 2 + end];
277            if !lit.is_empty() {
278                out.push(Part::Lit(std::mem::take(&mut lit)));
279            }
280            if let Some(k) = inner.strip_prefix("host:") {
281                out.push(Part::Host(k.to_string()));
282            } else if valid_var_name(inner) {
283                out.push(Part::Var(inner.to_string()));
284            } else {
285                return Err(format!(
286                    "${{{inner}}}: only ${{variable}} and ${{host:APP}} are expanded (write $${{ for a literal ${{)"
287                ));
288            }
289            i += 2 + end + 1;
290        } else {
291            let c = s[i..].chars().next().unwrap_or('\0');
292            lit.push(c);
293            i += c.len_utf8();
294        }
295    }
296    if !lit.is_empty() {
297        out.push(Part::Lit(lit));
298    }
299    Ok(out)
300}
301
302fn valid_var_name(s: &str) -> bool {
303    !s.is_empty()
304        && s.len() <= 64
305        && s.starts_with(|c: char| c.is_ascii_lowercase())
306        && s.chars()
307            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
308}
309
310fn valid_key(s: &str) -> bool {
311    !s.is_empty()
312        && s.len() <= 30
313        && s.starts_with(|c: char| c.is_ascii_lowercase())
314        && !s.ends_with('-')
315        && s.chars()
316            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
317}
318
319/// A rendered piece: text anyone may see, or a secret variable's value.
320#[derive(Debug, Clone, PartialEq, Eq)]
321enum Seg {
322    Lit(String),
323    Secret { var: String, value: String },
324}
325
326fn concat(segs: &[Seg]) -> String {
327    segs.iter()
328        .map(|s| match s {
329            Seg::Lit(t) => t.as_str(),
330            Seg::Secret { value, .. } => value.as_str(),
331        })
332        .collect()
333}
334
335fn has_secret(segs: &[Seg]) -> bool {
336    segs.iter().any(|s| matches!(s, Seg::Secret { .. }))
337}
338
339/// Every string a template holds, with where it is (for error messages),
340/// and whether it is the host of a domain.
341fn template_strings(t: &Template) -> Vec<(String, String)> {
342    let mut v = Vec::new();
343    for var in &t.variables {
344        for (what, s) in [("default", &var.default), ("value", &var.value)] {
345            if let Some(s) = s {
346                v.push((format!("variable {} {what}", var.name), s.clone()));
347            }
348        }
349        if let Some(j) = &var.jwt {
350            if let Some(p) = &j.payload {
351                v.push((format!("variable {} jwt payload", var.name), p.clone()));
352            }
353        }
354    }
355    for a in &t.apps {
356        let at = |w: &str| format!("app {} {w}", a.name);
357        v.push((at("image"), a.image.clone()));
358        for (k, s) in &a.env {
359            v.push((at(&format!("env {k}")), s.clone()));
360        }
361        for d in &a.domains {
362            for (k, s) in d {
363                if let Some(s) = s.as_str() {
364                    v.push((at(&format!("domain {k}")), s.to_string()));
365                }
366            }
367        }
368        for s in a.volumes.iter().chain(&a.ports) {
369            v.push((at("volumes/ports"), s.clone()));
370        }
371        for c in [&a.command, &a.args, &a.healthcheck].into_iter().flatten() {
372            collect_strings(c, &mut |s| {
373                v.push((at("command/healthcheck"), s.to_string()))
374            });
375        }
376        for f in &a.files {
377            v.push((at(&format!("file {}", f.path)), f.content.clone()));
378        }
379        for s in [&a.user, &a.working_dir].into_iter().flatten() {
380            v.push((at("user/working_dir"), s.clone()));
381        }
382    }
383    v
384}
385
386fn collect_strings(v: &Value, f: &mut dyn FnMut(&str)) {
387    match v {
388        Value::String(s) => f(s),
389        Value::Array(a) => a.iter().for_each(|x| collect_strings(x, f)),
390        Value::Object(o) => o.values().for_each(|x| collect_strings(x, f)),
391        _ => {}
392    }
393}
394
395fn argv(v: &Value, what: &str) -> Result<Vec<String>> {
396    match v {
397        Value::String(s) => {
398            crate::flex::split_words(s).map_err(|e| Error::invalid(format!("{what}: {e}")))
399        }
400        Value::Array(a) => a
401            .iter()
402            .map(|x| match x {
403                Value::String(s) => Ok(s.clone()),
404                Value::Number(n) => Ok(n.to_string()),
405                Value::Bool(b) => Ok(b.to_string()),
406                _ => Err(Error::invalid(format!("{what}: arguments are strings"))),
407            })
408            .collect(),
409        _ => Err(Error::invalid(format!(
410            "{what}: a list of arguments, or a command line"
411        ))),
412    }
413}
414
415impl Template {
416    /// Parse a template from YAML (or JSON) text.
417    pub fn from_yaml(text: &str) -> Result<Template> {
418        let v: Value =
419            serde_yaml_ng::from_str(text).map_err(|e| Error::invalid(format!("template: {e}")))?;
420        let t: Template =
421            serde_json::from_value(v).map_err(|e| Error::invalid(format!("template: {e}")))?;
422        t.validate()?;
423        Ok(t)
424    }
425
426    pub fn var(&self, name: &str) -> Option<&Variable> {
427        self.variables.iter().find(|v| v.name == name)
428    }
429
430    /// The key of the app named after the instance, if any.
431    pub fn main_key(&self) -> Option<&str> {
432        match &self.main {
433            Some(m) => Some(m.as_str()),
434            None if self.apps.len() == 1 => Some(self.apps[0].name.as_str()),
435            None => None,
436        }
437    }
438
439    /// Everything checkable without values: names, references, cycles.
440    pub fn validate(&self) -> Result<()> {
441        let bad = |m: String| Err(Error::invalid(format!("template {}: {m}", self.id)));
442        if !valid_key(&self.id) {
443            return bad("the id is [a-z0-9-], starting with a letter".into());
444        }
445        if self.name.trim().is_empty() {
446            return bad("a template needs a name".into());
447        }
448        if self.apps.is_empty() {
449            return bad("a template needs at least one app".into());
450        }
451        let mut names = BTreeSet::new();
452        for v in &self.variables {
453            if !valid_var_name(&v.name) {
454                return bad(format!(
455                    "variable {:?}: [a-z][a-z0-9_]*, at most 64 characters",
456                    v.name
457                ));
458            }
459            if !names.insert(v.name.as_str()) {
460                return bad(format!("variable {} is declared twice", v.name));
461            }
462            if v.kind == VarKind::Jwt && v.jwt.is_none() {
463                return bad(format!("variable {}: a jwt needs jwt.secret", v.name));
464            }
465            if let Some(j) = &v.jwt {
466                if !self.variables.iter().any(|x| x.name == j.secret) {
467                    return bad(format!(
468                        "variable {}: jwt.secret names no variable ({})",
469                        v.name, j.secret
470                    ));
471                }
472            }
473            if v.value.is_some() && v.kind.generated() {
474                return bad(format!(
475                    "variable {}: a generated type takes no value",
476                    v.name
477                ));
478            }
479        }
480        let mut keys = BTreeSet::new();
481        for a in &self.apps {
482            if !valid_key(&a.name) {
483                return bad(format!(
484                    "app {:?}: [a-z0-9-], starting with a letter, at most 30",
485                    a.name
486                ));
487            }
488            if !keys.insert(a.name.as_str()) {
489                return bad(format!("app {} is declared twice", a.name));
490            }
491            if a.command.is_some() && a.args.is_some() {
492                return bad(format!(
493                    "app {}: give command (the whole command line) or args (after the image's entrypoint), not both",
494                    a.name
495                ));
496            }
497        }
498        if let Some(m) = &self.main {
499            if !keys.contains(m.as_str()) {
500                return bad(format!("main names no app ({m})"));
501            }
502        }
503        for a in &self.apps {
504            for d in &a.depends_on {
505                if !keys.contains(d.as_str()) || d == &a.name {
506                    return bad(format!("app {}: depends_on {d}: no such other app", a.name));
507                }
508            }
509        }
510        self.order()?;
511        for (at, s) in template_strings(self) {
512            let parts = match parse_expr(&s) {
513                Ok(p) => p,
514                Err(e) => return bad(format!("{at}: {e}")),
515            };
516            for p in parts {
517                match p {
518                    Part::Var(v) if !names.contains(v.as_str()) => {
519                        return bad(format!("{at}: ${{{v}}} names no variable"));
520                    }
521                    Part::Host(k) if !keys.contains(k.as_str()) => {
522                        return bad(format!("{at}: ${{host:{k}}} names no app"));
523                    }
524                    _ => {}
525                }
526            }
527        }
528        self.var_order()?;
529        Ok(())
530    }
531
532    /// App keys in deploy order: dependencies first, else as written.
533    pub fn order(&self) -> Result<Vec<String>> {
534        let mut done: Vec<String> = Vec::new();
535        let mut left: Vec<&AppTemplate> = self.apps.iter().collect();
536        while !left.is_empty() {
537            let i = left
538                .iter()
539                .position(|a| a.depends_on.iter().all(|d| done.contains(d)))
540                .ok_or_else(|| {
541                    Error::invalid(format!(
542                        "template {}: depends_on has a cycle among {}",
543                        self.id,
544                        left.iter()
545                            .map(|a| a.name.as_str())
546                            .collect::<Vec<_>>()
547                            .join(", ")
548                    ))
549                })?;
550            done.push(left.remove(i).name.clone());
551        }
552        Ok(done)
553    }
554
555    /// Variables in an order where each comes after those it uses.
556    fn var_order(&self) -> Result<Vec<&Variable>> {
557        let deps = |v: &Variable| -> Vec<String> {
558            let mut out = Vec::new();
559            for s in [&v.value, &v.default].into_iter().flatten() {
560                for p in parse_expr(s).unwrap_or_default() {
561                    if let Part::Var(n) = p {
562                        out.push(n);
563                    }
564                }
565            }
566            if let Some(j) = &v.jwt {
567                out.push(j.secret.clone());
568                for p in j
569                    .payload
570                    .as_deref()
571                    .map(parse_expr)
572                    .and_then(|r| r.ok())
573                    .unwrap_or_default()
574                {
575                    if let Part::Var(n) = p {
576                        out.push(n);
577                    }
578                }
579            }
580            out
581        };
582        let mut done: Vec<&Variable> = Vec::new();
583        let mut left: Vec<&Variable> = self.variables.iter().collect();
584        while !left.is_empty() {
585            let i = left
586                .iter()
587                .position(|v| deps(v).iter().all(|d| done.iter().any(|x| &x.name == d)))
588                .ok_or_else(|| {
589                    Error::invalid(format!(
590                        "template {}: variables refer to each other in a cycle among {}",
591                        self.id,
592                        left.iter()
593                            .map(|v| v.name.as_str())
594                            .collect::<Vec<_>>()
595                            .join(", ")
596                    ))
597                })?;
598            done.push(left.remove(i));
599        }
600        Ok(done)
601    }
602}
603
604// --- instantiation --------------------------------------------------------
605
606/// Where a template is deployed, and the deployer's values.
607#[derive(Debug, Clone)]
608pub struct Params {
609    pub org: OrgId,
610    pub project: String,
611    pub environment: String,
612    /// Names the apps (`<instance>-<key>`) and the secrets.
613    pub instance: String,
614    pub values: BTreeMap<String, String>,
615}
616
617/// An image's entrypoint (`None`: it has none), for apps with `args`.
618pub type EntrypointFn = dyn Fn(&str) -> Result<Option<Vec<String>>> + Send + Sync;
619
620/// What the host contributes.
621pub struct Context<'a> {
622    /// For generated (`host: auto`) names.
623    pub public_ip: Option<IpAddr>,
624    pub entrypoint: &'a EntrypointFn,
625    pub free_port: &'a (dyn Fn() -> Option<u16> + Send + Sync),
626}
627
628/// A secret the deploy creates.
629#[derive(Debug, Clone, Serialize)]
630pub struct PlannedSecret {
631    pub name: String,
632    /// What it holds (`variable db_password`, `app web env DATABASE_URL`).
633    pub holds: String,
634    #[serde(skip)]
635    pub value: String,
636}
637
638/// A variable's outcome; secret values are never shown.
639#[derive(Debug, Clone, Serialize)]
640pub struct PlannedVar {
641    pub name: String,
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub value: Option<String>,
644    pub secret: bool,
645    /// `given`, `generated`, `default` or `computed`.
646    pub source: String,
647}
648
649/// Everything a deploy will create.
650#[derive(Debug, Clone, Serialize)]
651pub struct Plan {
652    pub template: String,
653    #[serde(skip_serializing_if = "String::is_empty")]
654    pub version: String,
655    pub instance: String,
656    pub project: String,
657    pub environment: String,
658    pub stack: String,
659    pub apps: Vec<AppSpec>,
660    /// App names, in deploy order.
661    pub order: Vec<String>,
662    pub secrets: Vec<PlannedSecret>,
663    pub variables: Vec<PlannedVar>,
664    /// `https://host/path` for each concrete domain.
665    pub urls: Vec<String>,
666    pub notes: Vec<String>,
667}
668
669/// The secret a template variable is stored as.
670pub fn var_secret(instance: &str, var: &str) -> String {
671    format!("tpl.{instance}.{var}")
672}
673
674/// The prefix of every secret an instance owns.
675pub fn secret_prefix(instance: &str) -> String {
676    format!("tpl.{instance}.")
677}
678
679/// The app name for template app `key` in `instance`.
680pub fn app_name(instance: &str, key: &str, main: bool) -> String {
681    if main || key == instance {
682        instance.to_string()
683    } else if key.starts_with(&format!("{instance}-")) {
684        key.to_string()
685    } else {
686        format!("{instance}-{key}")
687    }
688}
689
690#[derive(Debug, Clone)]
691struct Resolved {
692    /// `None`: a generated domain on a server without a public address.
693    value: Option<String>,
694    secret: bool,
695    /// For a domain variable: it means `host: auto`.
696    auto: bool,
697}
698
699struct Renderer<'a> {
700    p: &'a Params,
701    stack: String,
702    names: BTreeMap<String, String>,
703    vars: BTreeMap<String, Resolved>,
704}
705
706impl Renderer<'_> {
707    fn render(&self, s: &str, at: &str) -> Result<Vec<Seg>> {
708        let parts = parse_expr(s).map_err(|e| Error::invalid(format!("{at}: {e}")))?;
709        let mut out = Vec::new();
710        for p in parts {
711            match p {
712                Part::Lit(l) => out.push(Seg::Lit(l)),
713                Part::Host(k) => {
714                    let n = self.names.get(&k).ok_or_else(|| {
715                        Error::invalid(format!("{at}: ${{host:{k}}} names no app"))
716                    })?;
717                    out.push(Seg::Lit(format!("{n}.{}", self.stack)));
718                }
719                Part::Var(v) => {
720                    let r = self
721                        .vars
722                        .get(&v)
723                        .ok_or_else(|| Error::invalid(format!("{at}: ${{{v}}} is not set yet")))?;
724                    let value = r.value.clone().ok_or_else(|| {
725                        Error::invalid(format!(
726                            "{at}: ${{{v}}} is a generated hostname, and this server has no public address for one (isb serve --ingress-public-ip); give {v} a domain"
727                        ))
728                    })?;
729                    out.push(if r.secret {
730                        Seg::Secret { var: v, value }
731                    } else {
732                        Seg::Lit(value)
733                    });
734                }
735            }
736        }
737        Ok(out)
738    }
739
740    /// A string that must not hold a secret.
741    fn plain(&self, s: &str, at: &str) -> Result<String> {
742        let segs = self.render(s, at)?;
743        if has_secret(&segs) {
744            return Err(Error::invalid(format!(
745                "{at}: a secret variable cannot go here (only into env, files, command and healthcheck)"
746            )));
747        }
748        Ok(concat(&segs))
749    }
750}
751
752fn validate_input(v: &Variable, s: &str) -> std::result::Result<(), String> {
753    let n = s.chars().count() as u32;
754    if let Some(m) = v.min_length {
755        if n < m {
756            return Err(format!("at least {m} characters"));
757        }
758    }
759    if let Some(m) = v.max_length {
760        if n > m {
761            return Err(format!("at most {m} characters"));
762        }
763    }
764    if !v.choices.is_empty() && !v.choices.iter().any(|c| c == s) {
765        return Err(format!("one of {}", v.choices.join(", ")));
766    }
767    if s.contains('\0') {
768        return Err("no NUL characters".into());
769    }
770    match v.kind {
771        VarKind::Email => {
772            let ok = s.split_once('@').is_some_and(|(a, d)| {
773                !a.is_empty() && d.contains('.') && !d.starts_with('.') && !d.ends_with('.')
774            }) && !s.contains(char::is_whitespace);
775            if !ok {
776                return Err("an email address".into());
777            }
778        }
779        VarKind::Url => {
780            if !(s.starts_with("http://") || s.starts_with("https://"))
781                || s.contains(char::is_whitespace)
782            {
783                return Err("an http(s) URL".into());
784            }
785        }
786        VarKind::Int | VarKind::Port | VarKind::Timestamp => {
787            let i: i64 = s.parse().map_err(|_| "a whole number".to_string())?;
788            let (lo, hi) = match v.kind {
789                VarKind::Port => (Some(1), Some(65535)),
790                _ => (v.min, v.max),
791            };
792            if lo.is_some_and(|l| i < l) || hi.is_some_and(|h| i > h) {
793                return Err(format!(
794                    "between {} and {}",
795                    lo.map(|x| x.to_string()).unwrap_or("-".into()),
796                    hi.map(|x| x.to_string()).unwrap_or("-".into())
797                ));
798            }
799        }
800        VarKind::Domain => {
801            if !(s.is_empty() || s == "auto" || is_hostname(s)) {
802                return Err("a hostname (example.com), or empty for a generated one".into());
803            }
804        }
805        _ => {
806            if s.is_empty() && v.required() {
807                return Err("required".into());
808            }
809        }
810    }
811    Ok(())
812}
813
814fn is_hostname(s: &str) -> bool {
815    s.len() <= 253
816        && s.contains('.')
817        && s.split('.').all(|l| {
818            !l.is_empty()
819                && l.len() <= 63
820                && !l.starts_with('-')
821                && !l.ends_with('-')
822                && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
823        })
824}
825
826fn sh_single(s: &str) -> String {
827    format!("'{}'", s.replace('\'', "'\\''"))
828}
829
830/// The environment variable a secret is reached through in a shell line.
831fn secret_env_name(var: &str) -> String {
832    format!("ISB_TPL_{}", var.to_ascii_uppercase())
833}
834
835/// Render a template into what a deploy creates. Pure apart from the
836/// generators and `ctx`.
837pub fn plan(t: &Template, p: &Params, ctx: &Context) -> Result<Plan> {
838    t.validate()?;
839    crate::app::validate_app_name(&p.instance)
840        .map_err(|_| Error::invalid(format!("name {:?}: [a-z0-9-], starting with a letter, at most 30 characters (it names the apps)", p.instance)))?;
841    let stack = crate::app::stack_name(&p.project, &p.environment)?;
842    planning::check_values(t, p)?;
843    let names = planning::app_names(t, p)?;
844    let uses = planning::domain_uses(t);
845    let mut r = Renderer {
846        p,
847        stack: stack.clone(),
848        names,
849        vars: BTreeMap::new(),
850    };
851    let mut out = planning::Out::default();
852    let variables = planning::resolve_vars(t, &mut r, ctx, &uses, &mut out)?;
853    let apps = t
854        .apps
855        .iter()
856        .map(|a| planning::plan_app(a, &r, ctx, &uses, &mut out))
857        .collect::<Result<Vec<_>>>()?;
858    let order = t
859        .order()?
860        .into_iter()
861        .map(|k| r.names[&k].clone())
862        .collect();
863    let mut seen = BTreeSet::new();
864    out.secrets.retain(|s| seen.insert(s.name.clone()));
865    out.notes.extend(t.notes.iter().cloned());
866    Ok(Plan {
867        template: t.id.clone(),
868        version: t.version.clone(),
869        instance: p.instance.clone(),
870        project: p.project.clone(),
871        environment: p.environment.clone(),
872        stack,
873        apps,
874        order,
875        secrets: out.secrets,
876        variables,
877        urls: out.urls,
878        notes: out.notes,
879    })
880}
881
882/// An OCI image's entrypoint, read with `skopeo inspect --config` (within
883/// a minute). `None`: the image has none.
884pub fn skopeo_entrypoint(image: &str) -> Result<Option<Vec<String>>> {
885    use std::io::Read;
886    use std::time::{Duration, Instant};
887    let src = crate::plan::ImageSource::parse(image)?;
888    if !src.is_oci() {
889        return Err(Error::invalid(format!("{image} is not an OCI image")));
890    }
891    let host = src
892        .server
893        .as_deref()
894        .and_then(|s| s.strip_prefix("https://"))
895        .ok_or_else(|| Error::invalid(format!("{image}: no registry")))?;
896    let r = format!("docker://{host}/{}", src.alias);
897    let mut child = std::process::Command::new("skopeo")
898        .args(["inspect", "--config", &r])
899        .stdin(std::process::Stdio::null())
900        .stdout(std::process::Stdio::piped())
901        .stderr(std::process::Stdio::null())
902        .spawn()
903        .map_err(|e| Error::invalid(format!("skopeo: {e}")))?;
904    let mut out = Vec::new();
905    let mut stdout = child.stdout.take().expect("piped");
906    let reader = std::thread::spawn(move || {
907        let _ = stdout.read_to_end(&mut out);
908        out
909    });
910    let started = Instant::now();
911    let status = loop {
912        match child.try_wait()? {
913            Some(s) => break s,
914            None if started.elapsed() > Duration::from_secs(60) => {
915                let _ = child.kill();
916                let _ = child.wait();
917                return Err(Error::invalid(format!("skopeo inspect {r}: timed out")));
918            }
919            None => std::thread::sleep(Duration::from_millis(100)),
920        }
921    };
922    let out = reader.join().unwrap_or_default();
923    if !status.success() {
924        return Err(Error::invalid(format!("skopeo inspect {r} failed")));
925    }
926    let v: Value = serde_json::from_slice(&out)
927        .map_err(|e| Error::invalid(format!("skopeo inspect {r}: {e}")))?;
928    Ok(v["config"]["Entrypoint"].as_array().map(|a| {
929        a.iter()
930            .filter_map(|x| x.as_str().map(String::from))
931            .collect()
932    }))
933}
934
935/// A deployed instance, kept so it can be listed and removed as one.
936#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
937pub struct Instance {
938    pub name: String,
939    /// `catalog/id`.
940    pub template: String,
941    #[serde(default, skip_serializing_if = "String::is_empty")]
942    pub version: String,
943    pub project: String,
944    pub environment: String,
945    pub apps: Vec<String>,
946    pub secrets: Vec<String>,
947    /// Non-secret variable values.
948    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
949    pub variables: BTreeMap<String, String>,
950    #[serde(default, skip_serializing_if = "Vec::is_empty")]
951    pub urls: Vec<String>,
952    pub created_at: u64,
953    pub created_by: String,
954    /// Set when the deploy stopped early: the app that failed, why, and
955    /// the apps never started. Cleared when a redeploy finishes.
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub stopped: Option<Stopped>,
958}
959
960/// How a template deploy that did not finish stopped.
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962pub struct Stopped {
963    pub app: String,
964    pub reason: String,
965    /// Apps after `app` in the order that were never deployed.
966    #[serde(default)]
967    pub not_started: Vec<String>,
968}
969
970#[cfg(test)]
971mod tests;