1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use serde::{Deserialize, Serialize};
16
17use super::Template;
18use super::coolify;
19use super::dokploy::{self, Meta, Report};
20use crate::error::{Error, Result};
21
22pub const BUILTIN: &str = "builtin";
24
25const BUILTIN_FILES: &[(&str, &str)] = &[
27 ("plausible", include_str!("builtin/plausible.yaml")),
28 ("gitea", include_str!("builtin/gitea.yaml")),
29 ("n8n", include_str!("builtin/n8n.yaml")),
30 ("ghost", include_str!("builtin/ghost.yaml")),
31 ("umami", include_str!("builtin/umami.yaml")),
32 ("vaultwarden", include_str!("builtin/vaultwarden.yaml")),
33 ("minio", include_str!("builtin/minio.yaml")),
34 (
35 "postgres-adminer",
36 include_str!("builtin/postgres-adminer.yaml"),
37 ),
38 ("whoami", include_str!("builtin/whoami.yaml")),
39];
40
41pub fn builtin() -> Vec<Template> {
43 BUILTIN_FILES
44 .iter()
45 .filter_map(|(id, text)| match Template::from_yaml(text) {
46 Ok(t) => Some(t),
47 Err(e) => {
48 eprintln!("isb: built-in template {id}: {e}");
49 None
50 }
51 })
52 .collect()
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Format {
59 Native,
62 Dokploy,
66 Coolify,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct CatalogConfig {
77 pub name: String,
79 pub format: Format,
80 pub location: String,
82}
83
84impl CatalogConfig {
85 pub fn validate(&self) -> Result<()> {
86 if !super::valid_key(&self.name) || self.name == BUILTIN {
87 return Err(Error::invalid(format!(
88 "catalog name {:?}: [a-z0-9-], starting with a letter, not {BUILTIN}",
89 self.name
90 )));
91 }
92 if self.location.starts_with("https://") {
93 return Ok(());
94 }
95 let p = Path::new(&self.location);
96 if !p.is_absolute() {
97 return Err(Error::invalid(
98 "a catalog's location is an https:// URL or an absolute directory",
99 ));
100 }
101 if !p.is_dir() {
102 return Err(Error::invalid(format!(
103 "{} is not a directory",
104 self.location
105 )));
106 }
107 Ok(())
108 }
109
110 fn is_url(&self) -> bool {
111 self.location.starts_with("https://")
112 }
113}
114
115#[derive(Debug, Clone, Serialize)]
117pub struct Summary {
118 #[serde(rename = "ref")]
120 pub reference: String,
121 pub catalog: String,
122 pub id: String,
123 pub name: String,
124 pub description: String,
125 #[serde(skip_serializing_if = "String::is_empty")]
126 pub version: String,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub logo: Option<String>,
129 pub tags: Vec<String>,
130 pub links: BTreeMap<String, String>,
131 pub format: Format,
132}
133
134impl Summary {
135 fn of(catalog: &str, t: &Template, format: Format) -> Summary {
136 Summary {
137 reference: format!("{catalog}/{}", t.id),
138 catalog: catalog.into(),
139 id: t.id.clone(),
140 name: t.name.clone(),
141 description: t.description.clone(),
142 version: t.version.clone(),
143 logo: t.logo.clone(),
144 tags: t.tags.clone(),
145 links: t.links.clone(),
146 format,
147 }
148 }
149
150 fn coolify(catalog: &str, m: &coolify::Meta) -> Summary {
151 Summary {
152 reference: format!("{catalog}/{}", m.id),
153 catalog: catalog.into(),
154 id: m.id.clone(),
155 name: m.name.clone(),
156 description: m.description.clone(),
157 version: String::new(),
158 logo: m.logo.clone(),
159 tags: m.tags.clone(),
160 links: m
161 .docs
162 .iter()
163 .map(|d| ("docs".to_string(), d.clone()))
164 .collect(),
165 format: Format::Coolify,
166 }
167 }
168
169 pub fn matches(&self, query: &str, tag: Option<&str>) -> bool {
172 if let Some(t) = tag {
173 if !self.tags.iter().any(|x| x.eq_ignore_ascii_case(t)) {
174 return false;
175 }
176 }
177 let hay = format!(
178 "{} {} {} {}",
179 self.id,
180 self.name,
181 self.description,
182 self.tags.join(" ")
183 )
184 .to_ascii_lowercase();
185 query
186 .split_whitespace()
187 .all(|w| hay.contains(&w.to_ascii_lowercase()))
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct Resolved {
194 pub summary: Summary,
195 pub template: Option<Template>,
197 pub report: Option<Report>,
199}
200
201#[derive(Debug, Clone)]
202enum Entry {
203 Native(Template),
204 Dokploy {
207 meta: Meta,
208 base: String,
209 },
210 Coolify {
212 meta: coolify::Meta,
213 file: String,
214 },
215}
216
217pub type FetchFn = Arc<dyn Fn(&str) -> Result<Vec<u8>> + Send + Sync>;
219
220const CACHE_FOR: Duration = Duration::from_secs(600);
221const MAX_BODY: u64 = 4 << 20;
222
223type Cached<T> = (Instant, Arc<T>);
225
226pub struct Catalogs {
228 file: PathBuf,
229 fetch: FetchFn,
230 builtin: Vec<Template>,
231 cache: Mutex<BTreeMap<String, Cached<Vec<Entry>>>>,
232 files: Mutex<BTreeMap<String, Cached<Vec<u8>>>>,
233}
234
235pub fn http_fetch(url: &str) -> Result<Vec<u8>> {
237 if !url.starts_with("https://") {
238 return Err(Error::invalid(format!(
239 "{url}: catalogs are fetched over https only"
240 )));
241 }
242 let agent: ureq::Agent = ureq::Agent::config_builder()
243 .timeout_global(Some(Duration::from_secs(30)))
244 .http_status_as_error(false)
245 .max_redirects(3)
246 .user_agent(concat!("isb/", env!("CARGO_PKG_VERSION")))
247 .build()
248 .into();
249 let mut resp = agent
250 .get(url)
251 .call()
252 .map_err(|e| Error::invalid(format!("GET {url}: {e}")))?;
253 let status = resp.status().as_u16();
254 if !(200..300).contains(&status) {
255 return Err(Error::invalid(format!("GET {url}: HTTP {status}")));
256 }
257 resp.body_mut()
258 .with_config()
259 .limit(MAX_BODY)
260 .read_to_vec()
261 .map_err(|e| Error::invalid(format!("GET {url}: {e}")))
262}
263
264fn dokploy_id_ok(id: &str) -> bool {
267 !id.is_empty()
268 && id.len() <= 100
269 && !id.starts_with('.')
270 && !id.contains("..")
271 && id
272 .chars()
273 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
274}
275
276impl Catalogs {
277 pub fn new(state: &Path) -> Catalogs {
278 Catalogs::with_fetch(state, Arc::new(http_fetch))
279 }
280
281 pub fn with_fetch(state: &Path, fetch: FetchFn) -> Catalogs {
282 Catalogs {
283 file: state.join("templates").join("catalogs.json"),
284 fetch,
285 builtin: builtin(),
286 cache: Mutex::new(BTreeMap::new()),
287 files: Mutex::new(BTreeMap::new()),
288 }
289 }
290
291 pub fn configs(&self) -> Result<Vec<CatalogConfig>> {
293 match std::fs::read(&self.file) {
294 Ok(b) => Ok(serde_json::from_slice(&b)?),
295 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
296 Err(e) => Err(e.into()),
297 }
298 }
299
300 fn save(&self, c: &[CatalogConfig]) -> Result<()> {
301 crate::app::write_atomic(&self.file, &serde_json::to_vec_pretty(c)?)?;
302 self.cache.lock().unwrap().clear();
303 Ok(())
304 }
305
306 pub fn add(&self, mut c: CatalogConfig) -> Result<()> {
308 if c.format == Format::Coolify {
309 c.location = load_coolify::normalize_location(&c.location);
310 }
311 c.validate()?;
312 let mut all = self.configs()?;
313 all.retain(|x| x.name != c.name);
314 all.push(c);
315 self.save(&all)
316 }
317
318 pub fn remove(&self, name: &str) -> Result<()> {
319 let mut all = self.configs()?;
320 let n = all.len();
321 all.retain(|x| x.name != name);
322 if all.len() == n {
323 return Err(Error::NotFound(format!("catalog {name}")));
324 }
325 self.save(&all)
326 }
327
328 fn fetch_cached(&self, url: &str) -> Result<Arc<Vec<u8>>> {
330 if let Some((at, b)) = self.files.lock().unwrap().get(url) {
331 if at.elapsed() < CACHE_FOR {
332 return Ok(b.clone());
333 }
334 }
335 let b = Arc::new((self.fetch)(url)?);
336 self.files
337 .lock()
338 .unwrap()
339 .insert(url.to_string(), (Instant::now(), b.clone()));
340 Ok(b)
341 }
342
343 fn read(&self, cfg: &CatalogConfig, rel: &str) -> Result<Arc<Vec<u8>>> {
344 if cfg.is_url() {
345 self.fetch_cached(&format!("{}/{rel}", cfg.location.trim_end_matches('/')))
346 } else {
347 let p = Path::new(&cfg.location).join(rel);
348 let meta = std::fs::metadata(&p)
349 .map_err(|e| Error::invalid(format!("{}: {e}", p.display())))?;
350 if meta.len() > MAX_BODY {
351 return Err(Error::invalid(format!("{}: over 4 MiB", p.display())));
352 }
353 Ok(Arc::new(std::fs::read(&p)?))
354 }
355 }
356
357 fn blueprints(cfg: &CatalogConfig) -> &'static str {
359 if cfg.is_url() || Path::new(&cfg.location).join("blueprints").is_dir() {
360 "blueprints/"
361 } else {
362 ""
363 }
364 }
365
366 fn load(&self, cfg: &CatalogConfig) -> Result<Arc<Vec<Entry>>> {
367 if let Some((at, e)) = self.cache.lock().unwrap().get(&cfg.name) {
368 if at.elapsed() < CACHE_FOR {
369 return Ok(e.clone());
370 }
371 }
372 let entries = match cfg.format {
373 Format::Native => self.load_native(cfg)?,
374 Format::Dokploy => self.load_dokploy(cfg)?,
375 Format::Coolify => self.load_coolify(cfg)?,
376 };
377 let e = Arc::new(entries);
378 self.cache
379 .lock()
380 .unwrap()
381 .insert(cfg.name.clone(), (Instant::now(), e.clone()));
382 Ok(e)
383 }
384
385 fn load_native(&self, cfg: &CatalogConfig) -> Result<Vec<Entry>> {
386 let mut out = Vec::new();
387 if cfg.is_url() {
388 let b = self.read(cfg, "")?;
389 #[derive(Deserialize)]
390 struct Doc {
391 templates: Vec<serde_json::Value>,
392 }
393 let v: serde_json::Value = serde_yaml_ng::from_slice(&b)
394 .map_err(|e| Error::invalid(format!("catalog {}: {e}", cfg.name)))?;
395 let d: Doc = serde_json::from_value(v)
396 .map_err(|e| Error::invalid(format!("catalog {}: {e}", cfg.name)))?;
397 for t in d.templates {
398 match serde_json::from_value::<Template>(t) {
399 Ok(t) if t.validate().is_ok() => out.push(Entry::Native(t)),
400 _ => {}
401 }
402 }
403 } else {
404 let dir = Path::new(&cfg.location);
405 let mut paths = Vec::new();
406 for e in std::fs::read_dir(dir)?.flatten() {
407 let p = e.path();
408 if p.is_dir() {
409 let t = p.join("template.yaml");
410 if t.is_file() {
411 paths.push(t);
412 }
413 } else if p.extension().is_some_and(|x| x == "yaml" || x == "yml") {
414 paths.push(p);
415 }
416 }
417 paths.sort();
418 for p in paths {
419 let text = std::fs::read_to_string(&p)?;
420 match Template::from_yaml(&text) {
421 Ok(t) => out.push(Entry::Native(t)),
422 Err(e) => eprintln!("isb serve: catalog {}: {}: {e}", cfg.name, p.display()),
423 }
424 }
425 }
426 Ok(out)
427 }
428
429 fn load_dokploy(&self, cfg: &CatalogConfig) -> Result<Vec<Entry>> {
430 let prefix = Self::blueprints(cfg);
431 let metas: Vec<Meta> = match self.read(cfg, "meta.json") {
432 Ok(b) => serde_json::from_slice(&b)
433 .map_err(|e| Error::invalid(format!("catalog {}: meta.json: {e}", cfg.name)))?,
434 Err(e) if cfg.is_url() => return Err(e),
435 Err(_) => {
436 let root = Path::new(&cfg.location).join(prefix);
438 let mut v = Vec::new();
439 for e in std::fs::read_dir(&root)?.flatten() {
440 let id = e.file_name().to_string_lossy().to_string();
441 let m = e.path().join("meta.json");
442 let meta = std::fs::read(&m)
443 .ok()
444 .and_then(|b| serde_json::from_slice::<Meta>(&b).ok())
445 .unwrap_or(Meta {
446 id: id.clone(),
447 name: id.clone(),
448 ..Default::default()
449 });
450 if e.path().join("docker-compose.yml").is_file() {
451 v.push(Meta { id, ..meta });
452 }
453 }
454 v.sort_by(|a, b| a.id.cmp(&b.id));
455 v
456 }
457 };
458 let mut out = Vec::new();
459 for mut m in metas {
460 if !dokploy_id_ok(&m.id) {
461 continue;
462 }
463 let base = format!("{prefix}{}", m.id);
464 m.logo = match (&m.logo, cfg.is_url()) {
465 (Some(l), true) if dokploy_id_ok(l) => {
466 Some(format!("{}/{base}/{l}", cfg.location.trim_end_matches('/')))
467 }
468 _ => None,
469 };
470 out.push(Entry::Dokploy { meta: m, base });
471 }
472 Ok(out)
473 }
474
475 pub fn list(&self) -> (Vec<Summary>, Vec<String>) {
477 let mut out: Vec<Summary> = self
478 .builtin
479 .iter()
480 .map(|t| Summary::of(BUILTIN, t, Format::Native))
481 .collect();
482 let mut errors = Vec::new();
483 match self.configs() {
484 Ok(cfgs) => {
485 for c in cfgs {
486 match self.load(&c) {
487 Ok(entries) => {
488 for e in entries.iter() {
489 out.push(match e {
490 Entry::Native(t) => Summary::of(&c.name, t, Format::Native),
491 Entry::Dokploy { meta, .. } => Summary {
492 reference: format!("{}/{}", c.name, meta.id),
493 catalog: c.name.clone(),
494 id: meta.id.clone(),
495 name: meta.name.clone(),
496 description: meta.description.clone(),
497 version: meta.version.clone(),
498 logo: meta.logo.clone(),
499 tags: meta.tags.clone(),
500 links: meta.links.clone(),
501 format: Format::Dokploy,
502 },
503 Entry::Coolify { meta, .. } => Summary::coolify(&c.name, meta),
504 });
505 }
506 }
507 Err(e) => errors.push(format!("catalog {}: {e}", c.name)),
508 }
509 }
510 }
511 Err(e) => errors.push(format!("catalogs: {e}")),
512 }
513 (out, errors)
514 }
515
516 pub fn get(&self, reference: &str) -> Result<Resolved> {
520 let (catalog, id) = match reference.split_once('/') {
521 Some((c, i)) => (Some(c), i),
522 None => (None, reference),
523 };
524 if catalog.is_none_or(|c| c == BUILTIN) {
525 if let Some(t) = self.builtin.iter().find(|t| t.id == id) {
526 return Ok(Resolved {
527 summary: Summary::of(BUILTIN, t, Format::Native),
528 template: Some(t.clone()),
529 report: None,
530 });
531 }
532 if catalog.is_some() {
533 return Err(Error::NotFound(format!("template {reference}")));
534 }
535 }
536 for c in self.configs()? {
537 if catalog.is_some_and(|x| x != c.name) {
538 continue;
539 }
540 let entries = self.load(&c)?;
541 for e in entries.iter() {
542 match e {
543 Entry::Native(t) if t.id == id => {
544 return Ok(Resolved {
545 summary: Summary::of(&c.name, t, Format::Native),
546 template: Some(t.clone()),
547 report: None,
548 });
549 }
550 Entry::Dokploy { meta, base } if meta.id == id => {
551 let compose = self.read(&c, &format!("{base}/docker-compose.yml"))?;
552 let toml = self
553 .read(&c, &format!("{base}/template.toml"))
554 .map(|b| String::from_utf8_lossy(&b).into_owned())
555 .unwrap_or_default();
556 let (t, report) =
557 dokploy::translate(meta, &String::from_utf8_lossy(&compose), &toml);
558 let summary = Summary {
559 reference: format!("{}/{}", c.name, meta.id),
560 catalog: c.name.clone(),
561 id: meta.id.clone(),
562 name: meta.name.clone(),
563 description: meta.description.clone(),
564 version: meta.version.clone(),
565 logo: meta.logo.clone(),
566 tags: meta.tags.clone(),
567 links: meta.links.clone(),
568 format: Format::Dokploy,
569 };
570 return Ok(Resolved {
571 summary,
572 template: t,
573 report: Some(report),
574 });
575 }
576 Entry::Coolify { meta, file } if meta.id == id => {
577 let compose = self.read(&c, file)?;
578 let (t, report) =
579 coolify::translate(meta, &String::from_utf8_lossy(&compose));
580 return Ok(Resolved {
581 summary: Summary::coolify(&c.name, meta),
582 template: t,
583 report: Some(report),
584 });
585 }
586 _ => {}
587 }
588 }
589 }
590 Err(Error::NotFound(format!("template {reference}")))
591 }
592}
593
594mod load_coolify;
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn builtin_templates_parse() {
602 let b = builtin();
603 assert_eq!(
604 b.len(),
605 BUILTIN_FILES.len(),
606 "a built-in template failed to parse"
607 );
608 for ((id, _), t) in BUILTIN_FILES.iter().zip(&b) {
609 assert_eq!(*id, t.id);
610 assert!(!t.description.is_empty(), "{id}: description");
611 }
612 }
613
614 #[test]
615 fn catalogs_add_list_get() {
616 let dir = tempfile::tempdir().unwrap();
617 let cat = dir.path().join("cat");
618 std::fs::create_dir_all(cat.join("dokploy/blueprints/hello")).unwrap();
619 std::fs::write(
620 cat.join("dokploy/blueprints/hello/docker-compose.yml"),
621 "services:\n hello:\n image: traefik/whoami:v1.10\n restart: unless-stopped\n expose: [80]\n",
622 )
623 .unwrap();
624 std::fs::write(
625 cat.join("dokploy/blueprints/hello/template.toml"),
626 "[variables]\nmain_domain = \"${domain}\"\n\n[[config.domains]]\nserviceName = \"hello\"\nport = 80\nhost = \"${main_domain}\"\n",
627 )
628 .unwrap();
629 std::fs::write(
630 cat.join("dokploy/blueprints/hello/meta.json"),
631 r#"{"id":"hello","name":"Hello","description":"hi","tags":["test"]}"#,
632 )
633 .unwrap();
634 std::fs::create_dir_all(cat.join("native")).unwrap();
635 std::fs::write(
636 cat.join("native/echo.yaml"),
637 "id: echo\nname: Echo\ndescription: an echo server\napps:\n - name: web\n image: docker:traefik/whoami\n port: 80\n",
638 )
639 .unwrap();
640 let fetched = Arc::new(Mutex::new(Vec::<String>::new()));
641 let f2 = fetched.clone();
642 let c = Catalogs::with_fetch(
643 dir.path(),
644 Arc::new(move |u: &str| {
645 f2.lock().unwrap().push(u.to_string());
646 Err(Error::invalid("offline"))
647 }),
648 );
649 assert!(
650 c.add(CatalogConfig {
651 name: "builtin".into(),
652 format: Format::Native,
653 location: "/".into()
654 })
655 .is_err()
656 );
657 assert!(
658 c.add(CatalogConfig {
659 name: "x".into(),
660 format: Format::Native,
661 location: "relative".into()
662 })
663 .is_err()
664 );
665 c.add(CatalogConfig {
666 name: "dok".into(),
667 format: Format::Dokploy,
668 location: cat.join("dokploy").display().to_string(),
669 })
670 .unwrap();
671 c.add(CatalogConfig {
672 name: "mine".into(),
673 format: Format::Native,
674 location: cat.join("native").display().to_string(),
675 })
676 .unwrap();
677 c.add(CatalogConfig {
678 name: "remote".into(),
679 format: Format::Dokploy,
680 location: "https://templates.example.com".into(),
681 })
682 .unwrap();
683 let (all, errors) = c.list();
684 assert!(all.iter().any(|s| s.reference == "builtin/gitea"));
685 assert!(
686 all.iter()
687 .any(|s| s.reference == "dok/hello" && s.format == Format::Dokploy)
688 );
689 assert!(all.iter().any(|s| s.reference == "mine/echo"));
690 assert_eq!(errors.len(), 1, "{errors:?}");
691 assert!(errors[0].contains("remote"));
692 assert_eq!(
693 fetched.lock().unwrap().as_slice(),
694 ["https://templates.example.com/meta.json"]
695 );
696 let r = c.get("dok/hello").unwrap();
697 let t = r.template.unwrap();
698 assert_eq!(t.apps[0].image, "docker:traefik/whoami:v1.10");
699 assert_eq!(r.report.unwrap().status, dokploy::Status::Clean);
700 assert!(c.get("echo").unwrap().template.is_some());
701 assert!(c.get("gitea").unwrap().template.is_some());
702 assert!(c.get("dok/nope").is_err());
703 assert!(
704 all.iter()
705 .any(|s| s.reference == "mine/echo" && s.matches("echo server", None))
706 );
707 assert!(!all.iter().any(|s| s.matches("echo nonsense", None)));
708 assert!(all.iter().filter(|s| s.matches("", Some("test"))).count() == 1);
709 c.remove("remote").unwrap();
710 assert!(c.remove("remote").is_err());
711 assert_eq!(c.configs().unwrap().len(), 2);
712 }
713}