1use 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#[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 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 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 #[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 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, ¶ms, &ctx)?;
195 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 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 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 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 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 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 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
444fn 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
453fn 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
543fn 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;