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    /// What a new version of one of the app's secrets (its generated ones,
247    /// its files) does to its replicas: the app's `secret_on_change`.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub secret_on_change: Option<crate::spec::OnChange>,
250    /// Apps deployed (and converged) before this one.
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub depends_on: Vec<String>,
253}
254
255// --- expressions ----------------------------------------------------------
256
257/// A piece of a template string.
258#[derive(Debug, Clone, PartialEq, Eq)]
259enum Part {
260    Lit(String),
261    Var(String),
262    Host(String),
263}
264
265/// `${name}` is a variable, `${host:KEY}` an app's service name, `$$` a
266/// literal `$`; any other `$` is literal. Other `${...}` forms are errors.
267fn parse_expr(s: &str) -> std::result::Result<Vec<Part>, String> {
268    let mut out = Vec::new();
269    let mut lit = String::new();
270    let b = s.as_bytes();
271    let mut i = 0;
272    while i < b.len() {
273        if b[i] == b'$' && b.get(i + 1) == Some(&b'$') {
274            lit.push('$');
275            i += 2;
276        } else if b[i] == b'$' && b.get(i + 1) == Some(&b'{') {
277            let end = s[i + 2..]
278                .find('}')
279                .ok_or_else(|| format!("unterminated ${{ in {s:?}"))?;
280            let inner = &s[i + 2..i + 2 + end];
281            if !lit.is_empty() {
282                out.push(Part::Lit(std::mem::take(&mut lit)));
283            }
284            if let Some(k) = inner.strip_prefix("host:") {
285                out.push(Part::Host(k.to_string()));
286            } else if valid_var_name(inner) {
287                out.push(Part::Var(inner.to_string()));
288            } else {
289                return Err(format!(
290                    "${{{inner}}}: only ${{variable}} and ${{host:APP}} are expanded (write $${{ for a literal ${{)"
291                ));
292            }
293            i += 2 + end + 1;
294        } else {
295            let c = s[i..].chars().next().unwrap_or('\0');
296            lit.push(c);
297            i += c.len_utf8();
298        }
299    }
300    if !lit.is_empty() {
301        out.push(Part::Lit(lit));
302    }
303    Ok(out)
304}
305
306fn valid_var_name(s: &str) -> bool {
307    !s.is_empty()
308        && s.len() <= 64
309        && s.starts_with(|c: char| c.is_ascii_lowercase())
310        && s.chars()
311            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
312}
313
314fn valid_key(s: &str) -> bool {
315    !s.is_empty()
316        && s.len() <= 30
317        && s.starts_with(|c: char| c.is_ascii_lowercase())
318        && !s.ends_with('-')
319        && s.chars()
320            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
321}
322
323/// A rendered piece: text anyone may see, or a secret variable's value.
324#[derive(Debug, Clone, PartialEq, Eq)]
325enum Seg {
326    Lit(String),
327    Secret { var: String, value: String },
328}
329
330fn concat(segs: &[Seg]) -> String {
331    segs.iter()
332        .map(|s| match s {
333            Seg::Lit(t) => t.as_str(),
334            Seg::Secret { value, .. } => value.as_str(),
335        })
336        .collect()
337}
338
339fn has_secret(segs: &[Seg]) -> bool {
340    segs.iter().any(|s| matches!(s, Seg::Secret { .. }))
341}
342
343/// Every string a template holds, with where it is (for error messages),
344/// and whether it is the host of a domain.
345fn template_strings(t: &Template) -> Vec<(String, String)> {
346    let mut v = Vec::new();
347    for var in &t.variables {
348        for (what, s) in [("default", &var.default), ("value", &var.value)] {
349            if let Some(s) = s {
350                v.push((format!("variable {} {what}", var.name), s.clone()));
351            }
352        }
353        if let Some(j) = &var.jwt {
354            if let Some(p) = &j.payload {
355                v.push((format!("variable {} jwt payload", var.name), p.clone()));
356            }
357        }
358    }
359    for a in &t.apps {
360        let at = |w: &str| format!("app {} {w}", a.name);
361        v.push((at("image"), a.image.clone()));
362        for (k, s) in &a.env {
363            v.push((at(&format!("env {k}")), s.clone()));
364        }
365        for d in &a.domains {
366            for (k, s) in d {
367                if let Some(s) = s.as_str() {
368                    v.push((at(&format!("domain {k}")), s.to_string()));
369                }
370            }
371        }
372        for s in a.volumes.iter().chain(&a.ports) {
373            v.push((at("volumes/ports"), s.clone()));
374        }
375        for c in [&a.command, &a.args, &a.healthcheck].into_iter().flatten() {
376            collect_strings(c, &mut |s| {
377                v.push((at("command/healthcheck"), s.to_string()))
378            });
379        }
380        for f in &a.files {
381            v.push((at(&format!("file {}", f.path)), f.content.clone()));
382        }
383        for s in [&a.user, &a.working_dir].into_iter().flatten() {
384            v.push((at("user/working_dir"), s.clone()));
385        }
386    }
387    v
388}
389
390fn collect_strings(v: &Value, f: &mut dyn FnMut(&str)) {
391    match v {
392        Value::String(s) => f(s),
393        Value::Array(a) => a.iter().for_each(|x| collect_strings(x, f)),
394        Value::Object(o) => o.values().for_each(|x| collect_strings(x, f)),
395        _ => {}
396    }
397}
398
399fn argv(v: &Value, what: &str) -> Result<Vec<String>> {
400    match v {
401        Value::String(s) => {
402            crate::flex::split_words(s).map_err(|e| Error::invalid(format!("{what}: {e}")))
403        }
404        Value::Array(a) => a
405            .iter()
406            .map(|x| match x {
407                Value::String(s) => Ok(s.clone()),
408                Value::Number(n) => Ok(n.to_string()),
409                Value::Bool(b) => Ok(b.to_string()),
410                _ => Err(Error::invalid(format!("{what}: arguments are strings"))),
411            })
412            .collect(),
413        _ => Err(Error::invalid(format!(
414            "{what}: a list of arguments, or a command line"
415        ))),
416    }
417}
418
419impl Template {
420    /// Parse a template from YAML (or JSON) text.
421    pub fn from_yaml(text: &str) -> Result<Template> {
422        let v: Value =
423            serde_yaml_ng::from_str(text).map_err(|e| Error::invalid(format!("template: {e}")))?;
424        let t: Template =
425            serde_json::from_value(v).map_err(|e| Error::invalid(format!("template: {e}")))?;
426        t.validate()?;
427        Ok(t)
428    }
429
430    pub fn var(&self, name: &str) -> Option<&Variable> {
431        self.variables.iter().find(|v| v.name == name)
432    }
433
434    /// The key of the app named after the instance, if any.
435    pub fn main_key(&self) -> Option<&str> {
436        match &self.main {
437            Some(m) => Some(m.as_str()),
438            None if self.apps.len() == 1 => Some(self.apps[0].name.as_str()),
439            None => None,
440        }
441    }
442
443    /// Everything checkable without values: names, references, cycles.
444    pub fn validate(&self) -> Result<()> {
445        let bad = |m: String| Err(Error::invalid(format!("template {}: {m}", self.id)));
446        if !valid_key(&self.id) {
447            return bad("the id is [a-z0-9-], starting with a letter".into());
448        }
449        if self.name.trim().is_empty() {
450            return bad("a template needs a name".into());
451        }
452        if self.apps.is_empty() {
453            return bad("a template needs at least one app".into());
454        }
455        let mut names = BTreeSet::new();
456        for v in &self.variables {
457            if !valid_var_name(&v.name) {
458                return bad(format!(
459                    "variable {:?}: [a-z][a-z0-9_]*, at most 64 characters",
460                    v.name
461                ));
462            }
463            if !names.insert(v.name.as_str()) {
464                return bad(format!("variable {} is declared twice", v.name));
465            }
466            if v.kind == VarKind::Jwt && v.jwt.is_none() {
467                return bad(format!("variable {}: a jwt needs jwt.secret", v.name));
468            }
469            if let Some(j) = &v.jwt {
470                if !self.variables.iter().any(|x| x.name == j.secret) {
471                    return bad(format!(
472                        "variable {}: jwt.secret names no variable ({})",
473                        v.name, j.secret
474                    ));
475                }
476            }
477            if v.value.is_some() && v.kind.generated() {
478                return bad(format!(
479                    "variable {}: a generated type takes no value",
480                    v.name
481                ));
482            }
483        }
484        let mut keys = BTreeSet::new();
485        for a in &self.apps {
486            if !valid_key(&a.name) {
487                return bad(format!(
488                    "app {:?}: [a-z0-9-], starting with a letter, at most 30",
489                    a.name
490                ));
491            }
492            if !keys.insert(a.name.as_str()) {
493                return bad(format!("app {} is declared twice", a.name));
494            }
495            if a.command.is_some() && a.args.is_some() {
496                return bad(format!(
497                    "app {}: give command (the whole command line) or args (after the image's entrypoint), not both",
498                    a.name
499                ));
500            }
501        }
502        if let Some(m) = &self.main {
503            if !keys.contains(m.as_str()) {
504                return bad(format!("main names no app ({m})"));
505            }
506        }
507        for a in &self.apps {
508            for d in &a.depends_on {
509                if !keys.contains(d.as_str()) || d == &a.name {
510                    return bad(format!("app {}: depends_on {d}: no such other app", a.name));
511                }
512            }
513        }
514        self.order()?;
515        for (at, s) in template_strings(self) {
516            let parts = match parse_expr(&s) {
517                Ok(p) => p,
518                Err(e) => return bad(format!("{at}: {e}")),
519            };
520            for p in parts {
521                match p {
522                    Part::Var(v) if !names.contains(v.as_str()) => {
523                        return bad(format!("{at}: ${{{v}}} names no variable"));
524                    }
525                    Part::Host(k) if !keys.contains(k.as_str()) => {
526                        return bad(format!("{at}: ${{host:{k}}} names no app"));
527                    }
528                    _ => {}
529                }
530            }
531        }
532        self.var_order()?;
533        Ok(())
534    }
535
536    /// App keys in deploy order: dependencies first, else as written.
537    pub fn order(&self) -> Result<Vec<String>> {
538        let mut done: Vec<String> = Vec::new();
539        let mut left: Vec<&AppTemplate> = self.apps.iter().collect();
540        while !left.is_empty() {
541            let i = left
542                .iter()
543                .position(|a| a.depends_on.iter().all(|d| done.contains(d)))
544                .ok_or_else(|| {
545                    Error::invalid(format!(
546                        "template {}: depends_on has a cycle among {}",
547                        self.id,
548                        left.iter()
549                            .map(|a| a.name.as_str())
550                            .collect::<Vec<_>>()
551                            .join(", ")
552                    ))
553                })?;
554            done.push(left.remove(i).name.clone());
555        }
556        Ok(done)
557    }
558
559    /// Variables in an order where each comes after those it uses.
560    fn var_order(&self) -> Result<Vec<&Variable>> {
561        let deps = |v: &Variable| -> Vec<String> {
562            let mut out = Vec::new();
563            for s in [&v.value, &v.default].into_iter().flatten() {
564                for p in parse_expr(s).unwrap_or_default() {
565                    if let Part::Var(n) = p {
566                        out.push(n);
567                    }
568                }
569            }
570            if let Some(j) = &v.jwt {
571                out.push(j.secret.clone());
572                for p in j
573                    .payload
574                    .as_deref()
575                    .map(parse_expr)
576                    .and_then(|r| r.ok())
577                    .unwrap_or_default()
578                {
579                    if let Part::Var(n) = p {
580                        out.push(n);
581                    }
582                }
583            }
584            out
585        };
586        let mut done: Vec<&Variable> = Vec::new();
587        let mut left: Vec<&Variable> = self.variables.iter().collect();
588        while !left.is_empty() {
589            let i = left
590                .iter()
591                .position(|v| deps(v).iter().all(|d| done.iter().any(|x| &x.name == d)))
592                .ok_or_else(|| {
593                    Error::invalid(format!(
594                        "template {}: variables refer to each other in a cycle among {}",
595                        self.id,
596                        left.iter()
597                            .map(|v| v.name.as_str())
598                            .collect::<Vec<_>>()
599                            .join(", ")
600                    ))
601                })?;
602            done.push(left.remove(i));
603        }
604        Ok(done)
605    }
606}
607
608// --- instantiation --------------------------------------------------------
609
610/// Where a template is deployed, and the deployer's values.
611#[derive(Debug, Clone)]
612pub struct Params {
613    pub org: OrgId,
614    pub project: String,
615    pub environment: String,
616    /// Names the apps (`<instance>-<key>`) and the secrets.
617    pub instance: String,
618    pub values: BTreeMap<String, String>,
619}
620
621/// An image's entrypoint (`None`: it has none), for apps with `args`.
622pub type EntrypointFn = dyn Fn(&str) -> Result<Option<Vec<String>>> + Send + Sync;
623
624/// What the host contributes.
625pub struct Context<'a> {
626    /// For generated (`host: auto`) names.
627    pub public_ip: Option<IpAddr>,
628    pub entrypoint: &'a EntrypointFn,
629    pub free_port: &'a (dyn Fn() -> Option<u16> + Send + Sync),
630}
631
632/// A secret the deploy creates.
633#[derive(Debug, Clone, Serialize)]
634pub struct PlannedSecret {
635    pub name: String,
636    /// What it holds (`variable db_password`, `app web env DATABASE_URL`).
637    pub holds: String,
638    #[serde(skip)]
639    pub value: String,
640}
641
642/// A variable's outcome; secret values are never shown.
643#[derive(Debug, Clone, Serialize)]
644pub struct PlannedVar {
645    pub name: String,
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub value: Option<String>,
648    pub secret: bool,
649    /// `given`, `generated`, `default` or `computed`.
650    pub source: String,
651}
652
653/// Everything a deploy will create.
654#[derive(Debug, Clone, Serialize)]
655pub struct Plan {
656    pub template: String,
657    #[serde(skip_serializing_if = "String::is_empty")]
658    pub version: String,
659    pub instance: String,
660    pub project: String,
661    pub environment: String,
662    pub stack: String,
663    pub apps: Vec<AppSpec>,
664    /// App names, in deploy order.
665    pub order: Vec<String>,
666    pub secrets: Vec<PlannedSecret>,
667    pub variables: Vec<PlannedVar>,
668    /// `https://host/path` for each concrete domain.
669    pub urls: Vec<String>,
670    pub notes: Vec<String>,
671}
672
673/// The secret a template variable is stored as.
674pub fn var_secret(instance: &str, var: &str) -> String {
675    format!("tpl.{instance}.{var}")
676}
677
678/// The prefix of every secret an instance owns.
679pub fn secret_prefix(instance: &str) -> String {
680    format!("tpl.{instance}.")
681}
682
683/// The app name for template app `key` in `instance`.
684pub fn app_name(instance: &str, key: &str, main: bool) -> String {
685    if main || key == instance {
686        instance.to_string()
687    } else if key.starts_with(&format!("{instance}-")) {
688        key.to_string()
689    } else {
690        format!("{instance}-{key}")
691    }
692}
693
694#[derive(Debug, Clone)]
695struct Resolved {
696    /// `None`: a generated domain on a server without a public address.
697    value: Option<String>,
698    secret: bool,
699    /// For a domain variable: it means `host: auto`.
700    auto: bool,
701}
702
703struct Renderer<'a> {
704    p: &'a Params,
705    stack: String,
706    names: BTreeMap<String, String>,
707    vars: BTreeMap<String, Resolved>,
708}
709
710impl Renderer<'_> {
711    fn render(&self, s: &str, at: &str) -> Result<Vec<Seg>> {
712        let parts = parse_expr(s).map_err(|e| Error::invalid(format!("{at}: {e}")))?;
713        let mut out = Vec::new();
714        for p in parts {
715            match p {
716                Part::Lit(l) => out.push(Seg::Lit(l)),
717                Part::Host(k) => {
718                    let n = self.names.get(&k).ok_or_else(|| {
719                        Error::invalid(format!("{at}: ${{host:{k}}} names no app"))
720                    })?;
721                    out.push(Seg::Lit(format!("{n}.{}", self.stack)));
722                }
723                Part::Var(v) => {
724                    let r = self
725                        .vars
726                        .get(&v)
727                        .ok_or_else(|| Error::invalid(format!("{at}: ${{{v}}} is not set yet")))?;
728                    let value = r.value.clone().ok_or_else(|| {
729                        Error::invalid(format!(
730                            "{at}: ${{{v}}} is a generated hostname, and this server has no public address for one (isb serve --ingress-public-ip); give {v} a domain"
731                        ))
732                    })?;
733                    out.push(if r.secret {
734                        Seg::Secret { var: v, value }
735                    } else {
736                        Seg::Lit(value)
737                    });
738                }
739            }
740        }
741        Ok(out)
742    }
743
744    /// A string that must not hold a secret.
745    fn plain(&self, s: &str, at: &str) -> Result<String> {
746        let segs = self.render(s, at)?;
747        if has_secret(&segs) {
748            return Err(Error::invalid(format!(
749                "{at}: a secret variable cannot go here (only into env, files, command and healthcheck)"
750            )));
751        }
752        Ok(concat(&segs))
753    }
754}
755
756fn validate_input(v: &Variable, s: &str) -> std::result::Result<(), String> {
757    let n = s.chars().count() as u32;
758    if let Some(m) = v.min_length {
759        if n < m {
760            return Err(format!("at least {m} characters"));
761        }
762    }
763    if let Some(m) = v.max_length {
764        if n > m {
765            return Err(format!("at most {m} characters"));
766        }
767    }
768    if !v.choices.is_empty() && !v.choices.iter().any(|c| c == s) {
769        return Err(format!("one of {}", v.choices.join(", ")));
770    }
771    if s.contains('\0') {
772        return Err("no NUL characters".into());
773    }
774    match v.kind {
775        VarKind::Email => {
776            let ok = s.split_once('@').is_some_and(|(a, d)| {
777                !a.is_empty() && d.contains('.') && !d.starts_with('.') && !d.ends_with('.')
778            }) && !s.contains(char::is_whitespace);
779            if !ok {
780                return Err("an email address".into());
781            }
782        }
783        VarKind::Url => {
784            if !(s.starts_with("http://") || s.starts_with("https://"))
785                || s.contains(char::is_whitespace)
786            {
787                return Err("an http(s) URL".into());
788            }
789        }
790        VarKind::Int | VarKind::Port | VarKind::Timestamp => {
791            let i: i64 = s.parse().map_err(|_| "a whole number".to_string())?;
792            let (lo, hi) = match v.kind {
793                VarKind::Port => (Some(1), Some(65535)),
794                _ => (v.min, v.max),
795            };
796            if lo.is_some_and(|l| i < l) || hi.is_some_and(|h| i > h) {
797                return Err(format!(
798                    "between {} and {}",
799                    lo.map(|x| x.to_string()).unwrap_or("-".into()),
800                    hi.map(|x| x.to_string()).unwrap_or("-".into())
801                ));
802            }
803        }
804        VarKind::Domain => {
805            if !(s.is_empty() || s == "auto" || is_hostname(s)) {
806                return Err("a hostname (example.com), or empty for a generated one".into());
807            }
808        }
809        _ => {
810            if s.is_empty() && v.required() {
811                return Err("required".into());
812            }
813        }
814    }
815    Ok(())
816}
817
818fn is_hostname(s: &str) -> bool {
819    s.len() <= 253
820        && s.contains('.')
821        && s.split('.').all(|l| {
822            !l.is_empty()
823                && l.len() <= 63
824                && !l.starts_with('-')
825                && !l.ends_with('-')
826                && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
827        })
828}
829
830fn sh_single(s: &str) -> String {
831    format!("'{}'", s.replace('\'', "'\\''"))
832}
833
834/// The environment variable a secret is reached through in a shell line.
835fn secret_env_name(var: &str) -> String {
836    format!("ISB_TPL_{}", var.to_ascii_uppercase())
837}
838
839/// Render a template into what a deploy creates. Pure apart from the
840/// generators and `ctx`.
841pub fn plan(t: &Template, p: &Params, ctx: &Context) -> Result<Plan> {
842    t.validate()?;
843    crate::app::validate_app_name(&p.instance)
844        .map_err(|_| Error::invalid(format!("name {:?}: [a-z0-9-], starting with a letter, at most 30 characters (it names the apps)", p.instance)))?;
845    let stack = crate::app::stack_name(&p.project, &p.environment)?;
846    planning::check_values(t, p)?;
847    let names = planning::app_names(t, p)?;
848    let uses = planning::domain_uses(t);
849    let mut r = Renderer {
850        p,
851        stack: stack.clone(),
852        names,
853        vars: BTreeMap::new(),
854    };
855    let mut out = planning::Out::default();
856    let variables = planning::resolve_vars(t, &mut r, ctx, &uses, &mut out)?;
857    let apps = t
858        .apps
859        .iter()
860        .map(|a| planning::plan_app(a, &r, ctx, &uses, &mut out))
861        .collect::<Result<Vec<_>>>()?;
862    let order = t
863        .order()?
864        .into_iter()
865        .map(|k| r.names[&k].clone())
866        .collect();
867    let mut seen = BTreeSet::new();
868    out.secrets.retain(|s| seen.insert(s.name.clone()));
869    out.notes.extend(t.notes.iter().cloned());
870    Ok(Plan {
871        template: t.id.clone(),
872        version: t.version.clone(),
873        instance: p.instance.clone(),
874        project: p.project.clone(),
875        environment: p.environment.clone(),
876        stack,
877        apps,
878        order,
879        secrets: out.secrets,
880        variables,
881        urls: out.urls,
882        notes: out.notes,
883    })
884}
885
886/// An OCI image's entrypoint, read with `skopeo inspect --config` (within
887/// a minute). `None`: the image has none.
888pub fn skopeo_entrypoint(image: &str) -> Result<Option<Vec<String>>> {
889    use std::io::Read;
890    use std::time::{Duration, Instant};
891    let src = crate::plan::ImageSource::parse(image)?;
892    if !src.is_oci() {
893        return Err(Error::invalid(format!("{image} is not an OCI image")));
894    }
895    let host = src
896        .server
897        .as_deref()
898        .and_then(|s| s.strip_prefix("https://"))
899        .ok_or_else(|| Error::invalid(format!("{image}: no registry")))?;
900    let r = format!("docker://{host}/{}", src.alias);
901    let mut child = std::process::Command::new("skopeo")
902        .args(["inspect", "--config", &r])
903        .stdin(std::process::Stdio::null())
904        .stdout(std::process::Stdio::piped())
905        .stderr(std::process::Stdio::null())
906        .spawn()
907        .map_err(|e| Error::invalid(format!("skopeo: {e}")))?;
908    let mut out = Vec::new();
909    let mut stdout = child.stdout.take().expect("piped");
910    let reader = std::thread::spawn(move || {
911        let _ = stdout.read_to_end(&mut out);
912        out
913    });
914    let started = Instant::now();
915    let status = loop {
916        match child.try_wait()? {
917            Some(s) => break s,
918            None if started.elapsed() > Duration::from_secs(60) => {
919                let _ = child.kill();
920                let _ = child.wait();
921                return Err(Error::invalid(format!("skopeo inspect {r}: timed out")));
922            }
923            None => std::thread::sleep(Duration::from_millis(100)),
924        }
925    };
926    let out = reader.join().unwrap_or_default();
927    if !status.success() {
928        return Err(Error::invalid(format!("skopeo inspect {r} failed")));
929    }
930    let v: Value = serde_json::from_slice(&out)
931        .map_err(|e| Error::invalid(format!("skopeo inspect {r}: {e}")))?;
932    Ok(v["config"]["Entrypoint"].as_array().map(|a| {
933        a.iter()
934            .filter_map(|x| x.as_str().map(String::from))
935            .collect()
936    }))
937}
938
939/// A deployed instance, kept so it can be listed and removed as one.
940#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
941pub struct Instance {
942    pub name: String,
943    /// `catalog/id`.
944    pub template: String,
945    #[serde(default, skip_serializing_if = "String::is_empty")]
946    pub version: String,
947    pub project: String,
948    pub environment: String,
949    pub apps: Vec<String>,
950    pub secrets: Vec<String>,
951    /// Non-secret variable values.
952    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
953    pub variables: BTreeMap<String, String>,
954    #[serde(default, skip_serializing_if = "Vec::is_empty")]
955    pub urls: Vec<String>,
956    pub created_at: u64,
957    pub created_by: String,
958    /// Set when the deploy stopped early: the app that failed, why, and
959    /// the apps never started. Cleared when a redeploy finishes.
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub stopped: Option<Stopped>,
962}
963
964/// How a template deploy that did not finish stopped.
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
966pub struct Stopped {
967    pub app: String,
968    pub reason: String,
969    /// Apps after `app` in the order that were never deployed.
970    #[serde(default)]
971    pub not_started: Vec<String>,
972}
973
974#[cfg(test)]
975mod tests;