Skip to main content

isb_daemon/daemon/
templates.rs

1//! The template tools (`template_*`): the catalog, and deploying a
2//! template into a project environment as apps ([`crate::template`]).
3
4use std::collections::BTreeMap;
5use std::net::IpAddr;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::time::Duration;
9
10use serde::Deserialize;
11use serde_json::{Value, json};
12
13use super::{arg_org, args, caller_name, obj};
14use crate::app::Apps;
15use crate::app::deploy::{Status, Trigger};
16use crate::error::{Error, Result};
17use crate::org::OrgId;
18use crate::secrets::Secrets;
19use crate::server::{Caller, Registry, Tool};
20use crate::template::catalog::{CatalogConfig, Catalogs, Format};
21use crate::template::{self, EntrypointFn, Instance, Stopped, Template};
22
23/// What the template tools work with.
24#[derive(Clone)]
25pub struct Templates {
26    pub catalogs: Arc<Catalogs>,
27    apps: Apps,
28    secrets: Arc<Secrets>,
29    state: PathBuf,
30    public_ip: Option<IpAddr>,
31    entrypoint: Arc<EntrypointFn>,
32}
33
34impl Templates {
35    pub fn new(
36        state: &Path,
37        apps: Apps,
38        secrets: Arc<Secrets>,
39        public_ip: Option<IpAddr>,
40    ) -> Templates {
41        Templates {
42            catalogs: Arc::new(Catalogs::new(state)),
43            apps,
44            secrets,
45            state: state.to_path_buf(),
46            public_ip,
47            entrypoint: Arc::new(template::skopeo_entrypoint),
48        }
49    }
50
51    /// Use other catalogs and another entrypoint lookup (tests).
52    pub fn with(mut self, catalogs: Catalogs, entrypoint: Arc<EntrypointFn>) -> Templates {
53        self.catalogs = Arc::new(catalogs);
54        self.entrypoint = entrypoint;
55        self
56    }
57
58    fn instances_dir(&self, org: &OrgId) -> PathBuf {
59        crate::app::org_root(&self.state, org)
60            .join("templates")
61            .join("instances")
62    }
63
64    fn instance_path(&self, org: &OrgId, name: &str) -> Result<PathBuf> {
65        crate::app::validate_app_name(name)?;
66        Ok(self.instances_dir(org).join(format!("{name}.json")))
67    }
68
69    pub fn instances(&self, org: &OrgId) -> Result<Vec<Instance>> {
70        let mut out = Vec::new();
71        let rd = match std::fs::read_dir(self.instances_dir(org)) {
72            Ok(rd) => rd,
73            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
74            Err(e) => return Err(e.into()),
75        };
76        for e in rd.flatten() {
77            if e.path().extension().is_some_and(|x| x == "json") {
78                if let Ok(i) = serde_json::from_slice::<Instance>(&std::fs::read(e.path())?) {
79                    out.push(i);
80                }
81            }
82        }
83        out.sort_by(|a, b| a.name.cmp(&b.name));
84        Ok(out)
85    }
86
87    /// A template's inputs and what it creates, for `template_get`.
88    pub fn describe(&self, reference: &str) -> Result<Value> {
89        let r = self.catalogs.get(reference)?;
90        let mut out = json!({"template": r.summary});
91        if let Some(rep) = &r.report {
92            out["compatibility"] = serde_json::to_value(rep)?;
93        }
94        if let Some(t) = &r.template {
95            out["variables"] = json!(
96                t.variables
97                    .iter()
98                    .filter(|v| v.is_input())
99                    .map(|v| {
100                        let mut j = serde_json::to_value(v).unwrap_or_default();
101                        j["required"] = json!(
102                            v.required.unwrap_or(false)
103                                || (v.default.is_none()
104                                    && matches!(
105                                        v.kind,
106                                        template::VarKind::String
107                                            | template::VarKind::Email
108                                            | template::VarKind::Url
109                                            | template::VarKind::Int
110                                    ))
111                        );
112                        j["generated"] = json!(!matches!(
113                            v.kind,
114                            template::VarKind::String
115                                | template::VarKind::Email
116                                | template::VarKind::Url
117                                | template::VarKind::Int
118                                | template::VarKind::Domain
119                        ));
120                        j
121                    })
122                    .collect::<Vec<_>>()
123            );
124            out["apps"] = json!(
125                t.apps
126                    .iter()
127                    .map(|a| json!({
128                        "key": a.name,
129                        "image": a.image,
130                        "port": a.port,
131                        "domains": a.domains.len(),
132                        "volumes": a.volumes,
133                        "files": a.files.iter().map(|f| f.path.clone()).collect::<Vec<_>>(),
134                        "depends_on": a.depends_on,
135                    }))
136                    .collect::<Vec<_>>()
137            );
138            out["main"] = json!(t.main_key());
139            out["notes"] = json!(t.notes);
140            out["definition"] = serde_json::to_value(t)?;
141        }
142        Ok(out)
143    }
144
145    fn template(&self, reference: &str) -> Result<(String, Template)> {
146        let r = self.catalogs.get(reference)?;
147        match r.template {
148            Some(t) => Ok((r.summary.reference, t)),
149            None => Err(Error::invalid(format!(
150                "template {reference} cannot run on isb: {}",
151                r.report.map(|r| r.refusals.join("; ")).unwrap_or_default()
152            ))),
153        }
154    }
155
156    /// Plan, and unless `dry_run`, create the secrets and apps and deploy
157    /// them in order (in the background, or before returning with `wait`).
158    #[expect(
159        clippy::too_many_lines,
160        reason = "predates the lint ratchet; split it when next changed"
161    )]
162    pub fn deploy(&self, org: &OrgId, a: DeployArgs, c: &Caller) -> Result<Value> {
163        let (reference, t) = self.template(&a.template)?;
164        let instance = match &a.name {
165            Some(n) => n.clone(),
166            None => default_instance(&t.id),
167        };
168        let environment = a
169            .environment
170            .clone()
171            .unwrap_or_else(|| crate::app::DEFAULT_ENVIRONMENT.into());
172        let free = || template::generate::free_port();
173        let ctx = template::Context {
174            public_ip: self.public_ip,
175            entrypoint: self.entrypoint.as_ref(),
176            free_port: &free,
177        };
178        let params = template::Params {
179            org: org.clone(),
180            project: a.project.clone(),
181            environment: environment.clone(),
182            instance: instance.clone(),
183            values: a.values.clone(),
184        };
185        // A deploy that stopped early is finished by deploying again under
186        // the same name: only the apps that did not come up are run.
187        if !a.dry_run {
188            if let Some(inst) = self.read_instance(org, &instance)? {
189                if inst.stopped.is_some() && inst.template == reference {
190                    return self.resume(org, inst, &a, c);
191                }
192            }
193        }
194        let plan = template::plan(&t, &params, &ctx)?;
195        // Nothing may be in the way.
196        let mut conflicts = Vec::new();
197        if self.instance_path(org, &instance)?.exists() {
198            conflicts.push(format!("template instance {instance}"));
199        }
200        for app in &plan.apps {
201            if self.apps.get(org, &app.name).is_ok() {
202                conflicts.push(format!("app {}", app.name));
203            }
204        }
205        for s in &plan.secrets {
206            if self.secrets.inspect(org, &s.name).is_ok() {
207                conflicts.push(format!("secret {}", s.name));
208            }
209        }
210        let mut out = json!({"plan": plan, "ref": reference});
211        if !conflicts.is_empty() {
212            let msg = format!(
213                "{} already exist{} in org {org}; pick another name (name=...)",
214                conflicts.join(", "),
215                if conflicts.len() == 1 { "s" } else { "" }
216            );
217            if a.dry_run {
218                out["conflicts"] = json!(conflicts);
219                out["error"] = json!(msg);
220                return Ok(out);
221            }
222            return Err(Error::AlreadyExists(msg));
223        }
224        if a.dry_run {
225            out["dry_run"] = json!(true);
226            return Ok(out);
227        }
228        // The project and environment, made if missing.
229        match self.apps.project_get(org, &plan.project) {
230            Ok(p) if !p.environments.contains(&environment) => {
231                self.apps
232                    .environment_create(org, &plan.project, &environment)?;
233            }
234            Ok(_) => {}
235            Err(Error::NotFound(_)) => {
236                self.apps.project_create(
237                    org,
238                    &plan.project,
239                    "",
240                    std::slice::from_ref(&environment),
241                )?;
242            }
243            Err(e) => return Err(e),
244        }
245        let labels: BTreeMap<String, String> = [
246            ("isb.template".to_string(), reference.clone()),
247            ("isb.template.instance".to_string(), instance.clone()),
248        ]
249        .into_iter()
250        .collect();
251        let mut made_secrets = Vec::new();
252        let mut made_apps: Vec<String> = Vec::new();
253        let undo = |apps: &[String], secrets: &[String]| {
254            for a in apps.iter().rev() {
255                let _ = self.apps.delete(org, a);
256            }
257            for s in secrets {
258                let _ = self.secrets.delete(org, s);
259            }
260        };
261        for s in &plan.secrets {
262            if let Err(e) = self
263                .secrets
264                .create(org, &s.name, None, s.value.as_bytes(), &labels)
265            {
266                undo(&made_apps, &made_secrets);
267                return Err(e);
268            }
269            made_secrets.push(s.name.clone());
270        }
271        for app in &plan.apps {
272            if let Err(e) = self.apps.create(org, app.clone()) {
273                undo(&made_apps, &made_secrets);
274                return Err(e);
275            }
276            made_apps.push(app.name.clone());
277        }
278        let inst = Instance {
279            name: instance.clone(),
280            template: reference.clone(),
281            version: plan.version.clone(),
282            project: plan.project.clone(),
283            environment: environment.clone(),
284            apps: plan.order.clone(),
285            secrets: made_secrets.clone(),
286            variables: plan
287                .variables
288                .iter()
289                .filter_map(|v| v.value.clone().map(|x| (v.name.clone(), x)))
290                .collect(),
291            urls: plan.urls.clone(),
292            created_at: crate::stack::now_secs(),
293            created_by: caller_name(c),
294            stopped: None,
295        };
296        if let Err(e) = crate::app::write_atomic(
297            &self.instance_path(org, &instance)?,
298            &serde_json::to_vec_pretty(&inst)?,
299        ) {
300            undo(&made_apps, &made_secrets);
301            return Err(e);
302        }
303        out["instance"] = serde_json::to_value(&inst)?;
304        self.run_deploy(org, &inst, plan.order.clone(), &a, c, out)
305    }
306
307    fn read_instance(&self, org: &OrgId, name: &str) -> Result<Option<Instance>> {
308        match std::fs::read(self.instance_path(org, name)?) {
309            Ok(b) => Ok(Some(serde_json::from_slice(&b)?)),
310            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
311            Err(e) => Err(e.into()),
312        }
313    }
314
315    /// Record how a deploy ended on the instance: stopped early, or clean.
316    fn record_outcome(&self, org: &OrgId, name: &str, stopped: Option<Stopped>) {
317        let Ok(Some(mut inst)) = self.read_instance(org, name) else {
318            return;
319        };
320        if inst.stopped == stopped {
321            return;
322        }
323        inst.stopped = stopped;
324        let write = self
325            .instance_path(org, name)
326            .and_then(|p| crate::app::write_atomic(&p, &serde_json::to_vec_pretty(&inst)?));
327        if let Err(e) = write {
328            eprintln!("isb serve: template deploy in {org}: instance {name}: {e}");
329        }
330    }
331
332    /// Deploy the apps a stopped instance did not finish: the one that
333    /// failed and those after it.
334    fn resume(&self, org: &OrgId, inst: Instance, a: &DeployArgs, c: &Caller) -> Result<Value> {
335        let stopped = inst.stopped.clone().unwrap_or_else(|| Stopped {
336            app: String::new(),
337            reason: String::new(),
338            not_started: vec![],
339        });
340        let mut order: Vec<String> = inst
341            .apps
342            .iter()
343            .filter(|n| **n == stopped.app || stopped.not_started.contains(n))
344            .cloned()
345            .collect();
346        order.retain(|n| self.apps.get(org, n).is_ok());
347        let out = json!({"instance": serde_json::to_value(&inst)?, "resumed": order});
348        self.run_deploy(org, &inst, order, a, c, out)
349    }
350
351    fn run_deploy(
352        &self,
353        org: &OrgId,
354        inst: &Instance,
355        order: Vec<String>,
356        a: &DeployArgs,
357        c: &Caller,
358        mut out: Value,
359    ) -> Result<Value> {
360        let instance = inst.name.clone();
361        let trigger = if c.is_local() {
362            Trigger::Manual
363        } else {
364            Trigger::Api
365        };
366        let by = caller_name(c);
367        let timeout = match &a.timeout {
368            Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
369            None => Duration::from_secs(1800),
370        };
371        // Without `wait`, the first app's deploy is queued before answering,
372        // so the caller can open its live log at once.
373        let first = match (a.wait, order.first()) {
374            (false, Some(name)) => {
375                match self
376                    .apps
377                    .deploy(org, name, trigger, &by, Some("template".into()))
378                {
379                    Ok(d) => {
380                        out["first_deployment"] = json!({"app": name, "id": d.id});
381                        Some(d.id)
382                    }
383                    // The background run tries again and logs why.
384                    Err(_) => None,
385                }
386            }
387            _ => None,
388        };
389        let (me, org2, order2) = (self.clone(), org.clone(), order.clone());
390        let name = instance.clone();
391        let run = move || {
392            let (results, stopped) =
393                deploy_in_order(&me.apps, &org2, &order2, first, trigger, &by, timeout);
394            me.record_outcome(&org2, &name, stopped);
395            results
396        };
397        if a.wait {
398            let results = run();
399            out["deployments"] = json!(results);
400        } else {
401            std::thread::Builder::new()
402                .name(format!("template-{instance}"))
403                .spawn(move || {
404                    run();
405                })
406                .map_err(|e| Error::invalid(format!("start the deploy: {e}")))?;
407            out["deploying"] = json!(order);
408        }
409        Ok(out)
410    }
411
412    /// Delete an instance: its apps (named volumes are kept, as
413    /// `app_delete` keeps them), its secrets and its record.
414    pub fn remove(&self, org: &OrgId, name: &str) -> Result<Value> {
415        let p = self.instance_path(org, name)?;
416        let inst: Instance = match std::fs::read(&p) {
417            Ok(b) => serde_json::from_slice(&b)?,
418            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
419                return Err(Error::NotFound(format!("template instance {name}")));
420            }
421            Err(e) => return Err(e.into()),
422        };
423        let mut removed = Vec::new();
424        for a in inst.apps.iter().rev() {
425            match self.apps.delete(org, a) {
426                Ok(()) => removed.push(a.clone()),
427                Err(e) if e.is_not_found() => {}
428                Err(e) => return Err(e),
429            }
430        }
431        let prefix = template::secret_prefix(name);
432        let mut secrets = Vec::new();
433        for s in self.secrets.list(org)? {
434            if s.name.starts_with(&prefix) || inst.secrets.contains(&s.name) {
435                self.secrets.delete(org, &s.name)?;
436                secrets.push(s.name);
437            }
438        }
439        std::fs::remove_file(&p)?;
440        Ok(json!({"apps": removed, "secrets": secrets}))
441    }
442}
443
444/// The default instance name: the template id, short enough to name apps.
445fn default_instance(id: &str) -> String {
446    let mut s: String = id.chars().take(20).collect();
447    while s.ends_with('-') {
448        s.pop();
449    }
450    s
451}
452
453/// Deploy `order` one app after another (so a database is up before the
454/// app that needs it), stopping at the first that does not finish done.
455/// `first` is the first app's deployment when the caller already queued
456/// it. On a stop, deployments the template queued for the apps after it
457/// are cancelled, and the stop is returned for the instance record.
458fn deploy_in_order(
459    apps: &Apps,
460    org: &OrgId,
461    order: &[String],
462    first: Option<u64>,
463    trigger: Trigger,
464    by: &str,
465    timeout: Duration,
466) -> (Vec<Value>, Option<Stopped>) {
467    let mut out = Vec::new();
468    for (i, name) in order.iter().enumerate() {
469        let queued = match (i, first) {
470            (0, Some(id)) => apps.deployment(org, name, id),
471            _ => apps.deploy(org, name, trigger, by, Some("template".into())),
472        };
473        let d = queued.and_then(|d| apps.wait(org, name, d.id, timeout));
474        let reason = match d {
475            Ok(d) if d.status == Status::Done => {
476                out.push(json!({"app": name, "deployment": d.summary()}));
477                continue;
478            }
479            Ok(d) => {
480                eprintln!(
481                    "isb serve: template deploy in {org}: {name} did not deploy ({:?}); stopping",
482                    d.status
483                );
484                let why = match (&d.error, d.status.finished()) {
485                    (Some(e), _) => e.clone(),
486                    (None, true) => format!("{:?}", d.status).to_lowercase(),
487                    (None, false) => {
488                        format!("still {:?} after {timeout:?}", d.status).to_lowercase()
489                    }
490                };
491                out.push(json!({"app": name, "deployment": d.summary()}));
492                why
493            }
494            Err(e) => {
495                eprintln!("isb serve: template deploy in {org}: {name}: {e}");
496                out.push(json!({"app": name, "error": e.to_string()}));
497                e.to_string()
498            }
499        };
500        let rest = order[i + 1..].to_vec();
501        let why = format!("template deploy stopped: {name} failed");
502        for app in &rest {
503            for d in apps.deployments(org, app).unwrap_or_default() {
504                if d.status == Status::Queued && d.requested.as_deref() == Some("template") {
505                    let _ = apps.cancel_queued(org, app, d.id, &why);
506                }
507            }
508        }
509        return (
510            out,
511            Some(Stopped {
512                app: name.clone(),
513                reason,
514                not_started: rest,
515            }),
516        );
517    }
518    (out, None)
519}
520
521#[derive(Debug, Deserialize)]
522#[serde(deny_unknown_fields)]
523pub struct DeployArgs {
524    pub template: String,
525    pub project: String,
526    #[serde(default)]
527    pub environment: Option<String>,
528    #[serde(default)]
529    pub name: Option<String>,
530    #[serde(default, deserialize_with = "string_map")]
531    pub values: BTreeMap<String, String>,
532    #[serde(default)]
533    pub dry_run: bool,
534    #[serde(default)]
535    pub wait: bool,
536    #[serde(default)]
537    pub timeout: Option<String>,
538    #[serde(default)]
539    #[allow(dead_code)]
540    org: Option<String>,
541}
542
543/// `{K: "v" | 1 | true}` as strings.
544fn string_map<'de, D: serde::Deserializer<'de>>(
545    d: D,
546) -> std::result::Result<BTreeMap<String, String>, D::Error> {
547    let m: BTreeMap<String, Value> = BTreeMap::deserialize(d)?;
548    m.into_iter()
549        .map(|(k, v)| match v {
550            Value::String(s) => Ok((k, s)),
551            Value::Number(n) => Ok((k, n.to_string())),
552            Value::Bool(b) => Ok((k, b.to_string())),
553            _ => Err(serde::de::Error::custom(format!("{k}: a string"))),
554        })
555        .collect()
556}
557
558#[expect(
559    clippy::too_many_lines,
560    reason = "predates the lint ratchet; split it when next changed"
561)]
562pub fn register(r: &mut Registry, t: Templates) -> Result<()> {
563    let ro = json!({"readOnlyHint": true, "openWorldHint": true});
564    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
565    let write = json!({"destructiveHint": false, "openWorldHint": true});
566
567    macro_rules! tool {
568        ($name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
569            let t = t.clone();
570            let f = $f;
571            r.register(
572                Tool::new($name, $desc, $schema, move |a, c| f(&t, a, c))
573                    .title($title)
574                    .annotations($ann.clone()),
575            )?;
576        }};
577    }
578
579    tool!(
580        "template_list",
581        "List templates",
582        "One-click apps: the built-in catalog and any a platform admin added (isb's own format, or Dokploy's or Coolify's, translated). Each has a ref (catalog/id) for template_get and template_deploy. Filter with query (words in the name, description or tags), tag or catalog.",
583        obj(
584            json!({
585                "query": {"type": "string"},
586                "tag": {"type": "string"},
587                "catalog": {"type": "string"}
588            }),
589            &[]
590        ),
591        ro,
592        |t: &Templates, a: Value, _c: &Caller| -> Result<Value> {
593            #[derive(Deserialize)]
594            #[serde(deny_unknown_fields)]
595            struct A {
596                #[serde(default)]
597                query: String,
598                tag: Option<String>,
599                catalog: Option<String>,
600                #[serde(default)]
601                #[allow(dead_code)]
602                org: Option<String>,
603            }
604            let a: A = args(a)?;
605            let (all, errors) = t.catalogs.list();
606            let hits: Vec<_> = all
607                .into_iter()
608                .filter(|s| a.catalog.as_ref().is_none_or(|c| *c == s.catalog))
609                .filter(|s| s.matches(&a.query, a.tag.as_deref()))
610                .collect();
611            Ok(json!({"templates": hits, "errors": errors}))
612        }
613    );
614    tool!(
615        "template_get",
616        "Get a template",
617        "A template's metadata, its variables (what template_deploy takes in values: type, default, required, generated, secret), the apps it creates, notes, and for a Dokploy or Coolify template how its translation went (compatibility: clean, notes, or refused with reasons).",
618        obj(
619            json!({"template": {"type": "string", "description": "catalog/id, or a bare id."}}),
620            &["template"]
621        ),
622        ro,
623        |t: &Templates, a: Value, _c: &Caller| -> Result<Value> {
624            #[derive(Deserialize)]
625            #[serde(deny_unknown_fields)]
626            struct A {
627                template: String,
628                #[serde(default)]
629                #[allow(dead_code)]
630                org: Option<String>,
631            }
632            let a: A = args(a)?;
633            t.describe(&a.template)
634        }
635    );
636    tool!(
637        "template_deploy",
638        "Deploy a template",
639        "Deploy a template into a project environment as apps (made if missing): <name>-<app> per app (the main app just <name>), generated passwords and keys stored as org secrets tpl.<name>.<var>, then each app deployed in dependency order. dry_run=true returns the plan (apps, secrets by name, variables, URLs, notes) and changes nothing. Returns at once unless wait=true, with the first app's queued deployment as first_deployment {app, id} so its log can be followed from the first line.",
640        obj(
641            json!({
642                "template": {"type": "string", "description": "catalog/id, or a bare id."},
643                "project": {"type": "string"},
644                "environment": {"type": "string", "description": "Default production."},
645                "name": {"type": "string", "description": "The instance name: names the apps and secrets. Default: the template id."},
646                "values": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Variable values; generated ones may be left out."},
647                "dry_run": {"type": "boolean"},
648                "wait": {"type": "boolean", "description": "Return when every app has deployed (or one failed)."},
649                "timeout": {"type": "string", "description": "How long each app's deploy may take with wait (default 30m)."}
650            }),
651            &["template", "project"]
652        ),
653        write,
654        |t: &Templates, a: Value, c: &Caller| -> Result<Value> {
655            let org = arg_org(&a)?;
656            let a: DeployArgs = args(a)?;
657            t.deploy(&org, a, c)
658        }
659    );
660    tool!(
661        "template_instance_list",
662        "List deployed templates",
663        "The template instances in an org: template, project, environment, apps, secrets (names), non-secret variable values and URLs.",
664        obj(json!({}), &[]),
665        json!({"readOnlyHint": true, "openWorldHint": false}),
666        |t: &Templates, a: Value, _c: &Caller| -> Result<Value> {
667            let org = arg_org(&a)?;
668            Ok(json!({"instances": t.instances(&org)?}))
669        }
670    );
671    tool!(
672        "template_instance_delete",
673        "Delete a deployed template",
674        "Delete a template instance: its apps (their named volumes are kept), the secrets it made (tpl.<name>.*) and its record.",
675        obj(json!({"name": {"type": "string"}}), &["name"]),
676        destructive,
677        |t: &Templates, a: Value, _c: &Caller| -> Result<Value> {
678            let org = arg_org(&a)?;
679            let name = a
680                .get("name")
681                .and_then(Value::as_str)
682                .ok_or_else(|| Error::invalid("name is required"))?;
683            t.remove(&org, name)
684        }
685    );
686    tool!(
687        "template_catalog_list",
688        "List template catalogs",
689        "The catalogs added to the built-in one: name, format (native, dokploy or coolify) and location (a host directory or an https URL).",
690        obj(json!({}), &[]),
691        json!({"readOnlyHint": true, "openWorldHint": false}),
692        |t: &Templates, _a: Value, _c: &Caller| -> Result<Value> {
693            Ok(
694                json!({"builtin": crate::template::catalog::BUILTIN, "catalogs": t.catalogs.configs()?}),
695            )
696        }
697    );
698    tool!(
699        "template_catalog_add",
700        "Add a template catalog",
701        "Platform admins: add (or replace) a catalog every org can deploy from. format native (isb templates: a directory of *.yaml, or an https URL of a {templates: [...]} document) dokploy (a checkout of Dokploy/templates, or https://templates.dokploy.com) or coolify (a checkout of coollabsio/coolify, or its raw files at https://raw.githubusercontent.com/coollabsio/coolify/main). Its templates are third-party content: Dokploy's and Coolify's are translated strictly and refused when they need what isb does not allow.",
702        obj(
703            json!({
704                "name": {"type": "string"},
705                "format": {"type": "string", "enum": ["native", "dokploy", "coolify"]},
706                "location": {"type": "string"}
707            }),
708            &["name", "format", "location"]
709        ),
710        json!({"destructiveHint": false, "openWorldHint": true}),
711        |t: &Templates, a: Value, _c: &Caller| -> Result<Value> {
712            #[derive(Deserialize)]
713            #[serde(deny_unknown_fields)]
714            struct A {
715                name: String,
716                format: Format,
717                location: String,
718                #[serde(default)]
719                #[allow(dead_code)]
720                org: Option<String>,
721            }
722            let a: A = args(a)?;
723            let c = CatalogConfig {
724                name: a.name,
725                format: a.format,
726                location: a.location,
727            };
728            t.catalogs.add(c.clone())?;
729            Ok(json!({"catalog": c}))
730        }
731    );
732    tool!(
733        "template_catalog_remove",
734        "Remove a template catalog",
735        "Platform admins: remove an added catalog. Instances deployed from it keep running.",
736        obj(json!({"name": {"type": "string"}}), &["name"]),
737        destructive,
738        |t: &Templates, a: Value, _c: &Caller| -> Result<Value> {
739            let name = a
740                .get("name")
741                .and_then(Value::as_str)
742                .ok_or_else(|| Error::invalid("name is required"))?;
743            t.catalogs.remove(name)?;
744            Ok(json!({"ok": true}))
745        }
746    );
747    Ok(())
748}
749
750pub mod logo;
751
752#[cfg(test)]
753mod tests;