Skip to main content

isb_apps/template/
catalog.rs

1//! Where templates come from: the catalog built into the binary, and the
2//! catalogs a platform admin adds (a directory on the host or an https
3//! URL; isb's own format, Dokploy's or Coolify's).
4//!
5//! Added catalogs are kept in `<state>/templates/catalogs.json`. What they
6//! hold is third-party data: it is parsed, never run, and a Dokploy or
7//! Coolify template is translated strictly ([`super::dokploy`],
8//! [`super::coolify`]).
9
10use 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
22/// The built-in catalog's name.
23pub const BUILTIN: &str = "builtin";
24
25/// The templates compiled into isb (`builtin/` next to this file).
26const 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
41/// The built-in templates.
42pub 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/// A catalog's format.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Format {
59    /// isb templates: a directory of `*.yaml` (or `<id>/template.yaml`), or
60    /// a URL of one YAML/JSON document `{templates: [...]}`.
61    Native,
62    /// Dokploy's: `meta.json` plus `blueprints/<id>/{docker-compose.yml,
63    /// template.toml}`, in a directory (a checkout of Dokploy/templates) or
64    /// at a URL (`https://templates.dokploy.com`).
65    Dokploy,
66    /// Coolify's: `templates/compose/<id>.yaml` (one compose file with its
67    /// metadata in comments), in a directory (a checkout of
68    /// coollabsio/coolify) or at a URL (its raw files, such as
69    /// `https://raw.githubusercontent.com/coollabsio/coolify/main`).
70    Coolify,
71}
72
73/// An added catalog.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct CatalogConfig {
77    /// `[a-z0-9-]`; template refs are `<name>/<id>`.
78    pub name: String,
79    pub format: Format,
80    /// An absolute directory, or an `https://` URL.
81    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/// A template as a listing shows it.
116#[derive(Debug, Clone, Serialize)]
117pub struct Summary {
118    /// `<catalog>/<id>`: what `template_get` and `template_deploy` take.
119    #[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    /// Does it match a search: every word in the name, id, description or
170    /// tags.
171    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/// A template, ready to plan, with how it was obtained.
192#[derive(Debug, Clone)]
193pub struct Resolved {
194    pub summary: Summary,
195    /// `None` when a Dokploy template was refused.
196    pub template: Option<Template>,
197    /// For a Dokploy template: how the translation went.
198    pub report: Option<Report>,
199}
200
201#[derive(Debug, Clone)]
202enum Entry {
203    Native(Template),
204    /// A Dokploy blueprint: its metadata and where its files are (a
205    /// directory, or a URL prefix).
206    Dokploy {
207        meta: Meta,
208        base: String,
209    },
210    /// A Coolify template: its metadata and where its compose file is.
211    Coolify {
212        meta: coolify::Meta,
213        file: String,
214    },
215}
216
217/// Reads a URL (https only, bounded); a test can replace it.
218pub 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
223/// A value and when it was read.
224type Cached<T> = (Instant, Arc<T>);
225
226/// The catalogs of one daemon.
227pub 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
235/// GET an https URL, at most 4 MiB, within 30 s.
236pub 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
264/// A Dokploy template id: letters, digits, `.`, `_`, `-` (it names a
265/// directory and a URL path).
266fn 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    /// The added catalogs.
292    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    /// Add (or replace) a catalog.
307    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    /// An https URL's body, reused for a while.
329    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    /// Where a Dokploy catalog's blueprints are, relative to its location.
358    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                // A checkout: each blueprint has its own meta.json.
437                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    /// Every template, with errors from catalogs that could not be read.
476    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    /// A template by `catalog/id`, or a bare id (the built-in catalog
517    /// first, then the added ones in order). A Dokploy template is
518    /// translated here.
519    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}