1use crate::daemon_id::DaemonId;
13use crate::pitchfork_toml::PitchforkTomlDaemon;
14use crate::settings::settings;
15use indexmap::IndexMap;
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19#[derive(Debug, Clone)]
25pub struct DaemonTemplateState {
26 pub ports: Vec<u16>,
27 pub id: String,
28 pub name: String,
29 pub namespace: String,
30 pub slug: Option<String>,
31 pub host: Option<String>,
33 pub dir: PathBuf,
34}
35
36impl DaemonTemplateState {
37 fn port(&self) -> Option<u16> {
38 self.ports.first().copied()
39 }
40}
41
42pub struct TemplateContext {
48 self_state: DaemonTemplateState,
49 daemon_states: HashMap<String, DaemonTemplateState>,
50 env: Option<IndexMap<String, String>>,
55}
56
57impl TemplateContext {
58 pub fn new(
65 id: &DaemonId,
66 daemon_config: &PitchforkTomlDaemon,
67 resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
68 daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
69 ) -> Self {
70 let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
71 let proxy_enabled = settings().proxy.enable;
75 let host_of = |id: &DaemonId, config: &PitchforkTomlDaemon| {
76 proxy_enabled
77 .then(|| crate::proxy::hostname::host_for_daemon(id, Some(config), &global_slugs))
78 .flatten()
79 };
80 let effective_user = daemon_config.effective_user();
81 let dir = crate::ipc::batch::resolve_daemon_dir(
82 daemon_config.dir.as_deref(),
83 daemon_config.path.as_deref(),
84 effective_user.as_deref(),
85 );
86
87 let self_state = DaemonTemplateState {
88 ports: Vec::new(),
89 id: id.qualified(),
90 name: id.name().to_string(),
91 namespace: id.namespace().to_string(),
92 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
93 id,
94 &global_slugs,
95 ),
96 host: host_of(id, daemon_config),
97 dir,
98 };
99
100 let mut daemon_states = HashMap::new();
101 for (dep_id, ports) in resolved_daemons {
102 if let Some(config) = daemon_configs.get(dep_id) {
103 let dep_effective_user = config.effective_user();
104 let dep_dir = crate::ipc::batch::resolve_daemon_dir(
105 config.dir.as_deref(),
106 config.path.as_deref(),
107 dep_effective_user.as_deref(),
108 );
109 let state = DaemonTemplateState {
110 ports: ports.clone(),
111 id: dep_id.qualified(),
112 name: dep_id.name().to_string(),
113 namespace: dep_id.namespace().to_string(),
114 slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
115 dep_id,
116 &global_slugs,
117 ),
118 host: host_of(dep_id, config),
119 dir: dep_dir,
120 };
121
122 if dep_id.namespace() == id.namespace() {
124 daemon_states.insert(dep_id.name().to_string(), state.clone());
125 }
126
127 daemon_states.insert(qualified_key(dep_id), state);
129 }
130 }
131
132 Self {
133 self_state,
134 daemon_states,
135 env: None,
136 }
137 }
138
139 pub fn set_env(&mut self, env: IndexMap<String, String>) {
144 self.env = Some(env);
145 }
146
147 pub fn to_tera_context(&self) -> tera::Context {
149 let mut ctx = tera::Context::new();
150
151 ctx.insert("name", &self.self_state.name);
153 ctx.insert("namespace", &self.self_state.namespace);
154 ctx.insert("id", &self.self_state.id);
155 ctx.insert("slug", &self.self_state.slug);
156 ctx.insert("host", &self.self_state.host);
157 ctx.insert("dir", &self.self_state.dir.to_string_lossy().to_string());
158
159 let mut daemons_map = serde_json::Map::new();
161 for (name, state) in &self.daemon_states {
162 if daemons_map.contains_key(name) {
163 continue;
164 }
165 daemons_map.insert(name.clone(), daemon_state_to_json(state));
166 }
167 ctx.insert("daemons", &serde_json::Value::Object(daemons_map));
168
169 let s = settings();
171 ctx.insert(
172 "settings",
173 &serde_json::json!({
174 "proxy": {
175 "enable": s.proxy.enable,
176 "tld": s.proxy.tld,
177 "port": s.proxy.port,
178 "https": s.proxy.https,
179 }
180 }),
181 );
182
183 let proxy_url = crate::proxy::build_proxy_url(self.self_state.host.as_deref(), &s);
186 ctx.insert("proxy_url", &proxy_url);
187 ctx.insert("url", &proxy_url);
189
190 if let Some(ref env) = self.env {
192 let map: serde_json::Map<String, serde_json::Value> = env
193 .iter()
194 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
195 .collect();
196 ctx.insert("env", &serde_json::Value::Object(map));
197 }
198
199 ctx
200 }
201}
202
203fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
204 serde_json::json!({
205 "port": state.port(),
206 "ports": state.ports,
207 "id": state.id,
208 "name": state.name,
209 "namespace": state.namespace,
210 "slug": state.slug,
211 "host": state.host,
212 "url": crate::proxy::build_proxy_url(state.host.as_deref(), &settings()),
213 "dir": state.dir.to_string_lossy(),
214 })
215}
216
217fn qualified_key(id: &DaemonId) -> String {
220 format!("{}.{}", id.namespace(), id.name())
221}
222
223pub(crate) fn merge_env(
229 top: Option<&IndexMap<String, String>>,
230 daemon: Option<&IndexMap<String, String>>,
231) -> Option<IndexMap<String, String>> {
232 match (top, daemon) {
233 (None, None) => None,
234 (Some(t), None) => Some(t.clone()),
235 (None, Some(d)) => Some(d.clone()),
236 (Some(t), Some(d)) => {
237 let mut merged = t.clone();
238 for (k, v) in d {
239 merged.insert(k.clone(), v.clone());
240 }
241 Some(merged)
242 }
243 }
244}
245
246pub fn render_env(
254 top_env: Option<&IndexMap<String, String>>,
255 daemon_env: Option<&IndexMap<String, String>>,
256 context: &TemplateContext,
257) -> Result<Option<IndexMap<String, String>>, RenderError> {
258 let Some(merged) = merge_env(top_env, daemon_env) else {
259 return Ok(None);
260 };
261 let mut renderer = TemplateRenderer::new(context);
262 let rendered: IndexMap<String, String> = merged
263 .iter()
264 .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
265 .collect::<Result<_, _>>()?;
266 Ok(Some(rendered))
267}
268
269pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
278 TemplateRenderer::new(context).render(template)
279}
280
281pub fn render_daemon_templates(
294 config: &mut PitchforkTomlDaemon,
295 context: &mut TemplateContext,
296 top_env: Option<&IndexMap<String, String>>,
297) -> Result<(), RenderError> {
298 let rendered_env = render_env(top_env, config.env.as_ref(), context)?;
303 config.env = rendered_env;
304
305 if let Some(ref env) = config.env {
308 context.set_env(env.clone());
309 }
310
311 let mut renderer = TemplateRenderer::new(context);
312
313 config.run = config.run.try_map(|arg| renderer.render(arg))?;
316
317 if let Some(ref hooks) = config.hooks {
318 let rendered = crate::config_types::PitchforkTomlHooks {
319 on_ready: hooks
320 .on_ready
321 .as_deref()
322 .and_then(|t| renderer.render(t).ok()),
323 on_fail: hooks
324 .on_fail
325 .as_deref()
326 .and_then(|t| renderer.render(t).ok()),
327 on_retry: hooks
328 .on_retry
329 .as_deref()
330 .and_then(|t| renderer.render(t).ok()),
331 on_stop: hooks
332 .on_stop
333 .as_deref()
334 .and_then(|t| renderer.render(t).ok()),
335 on_exit: hooks
336 .on_exit
337 .as_deref()
338 .and_then(|t| renderer.render(t).ok()),
339 on_output: hooks.on_output.as_ref().and_then(|hook| {
340 renderer
341 .render(&hook.run)
342 .ok()
343 .map(|run| crate::config_types::OnOutputHook {
344 run,
345 filter: hook.filter.clone(),
346 regex: hook.regex.clone(),
347 debounce: hook.debounce.clone(),
348 })
349 }),
350 };
351 config.hooks = Some(rendered);
352 }
353
354 if let Some(ref cmd) = config.ready_cmd {
355 config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
356 run: renderer.render(&cmd.run)?,
357 timeout: cmd.timeout,
358 });
359 }
360
361 if let Some(ref hc) = config.health_cmd {
362 config.health_cmd = Some(crate::pitchfork_toml::HealthCmd {
363 run: renderer.render(&hc.run)?,
364 interval: hc.interval,
365 timeout: hc.timeout,
366 retries: hc.retries,
367 });
368 }
369
370 if let Some(ref http) = config.health_http {
371 let mut http = http.clone();
372 http.url = renderer.render(&http.url)?;
373 config.health_http = Some(http);
374 }
375
376 if let Some(ref health_port) = config.health_port
377 && let Some(ref template) = health_port.template
378 {
379 let rendered = renderer.render(template)?;
380 let port = rendered
381 .trim()
382 .parse::<u16>()
383 .ok()
384 .filter(|&p| p > 0)
385 .ok_or_else(|| RenderError::InvalidPort {
386 field: "health_port",
387 template: template.clone(),
388 rendered: rendered.clone(),
389 })?;
390 config.health_port = Some(crate::config_types::HealthPort {
391 port: Some(port),
392 template: None,
393 interval: health_port.interval,
394 retries: health_port.retries,
395 timeout: health_port.timeout,
396 });
397 }
398
399 if let Some(ref output) = config.ready_output {
400 let pattern = renderer.render(&output.pattern)?;
401 config.ready_output = Some(crate::config_types::ReadyOutput {
402 pattern,
403 timeout: output.timeout,
404 });
405 }
406
407 if let Some(ref http) = config.ready_http {
408 let mut http = http.clone();
409 http.url = renderer.render(&http.url)?;
410 config.ready_http = Some(http);
411 }
412
413 if let Some(ref ready_port) = config.ready_port
414 && let Some(ref template) = ready_port.template
415 {
416 let rendered = renderer.render(template)?;
417 let port = rendered
418 .trim()
419 .parse::<u16>()
420 .ok()
421 .filter(|&p| p > 0)
422 .ok_or_else(|| RenderError::InvalidPort {
423 field: "ready_port",
424 template: template.clone(),
425 rendered: rendered.clone(),
426 })?;
427 config.ready_port = Some(crate::config_types::ReadyPort {
428 port: Some(port),
429 template: None,
430 timeout: ready_port.timeout,
431 });
432 }
433
434 Ok(())
435}
436
437fn contains_template_syntax(template: &str) -> bool {
438 template.contains("{{") || template.contains("{%") || template.contains("{#")
439}
440
441struct TemplateRenderer {
442 tera: tera::Tera,
443 context: tera::Context,
444 next_template_id: usize,
445}
446
447impl TemplateRenderer {
448 fn new(context: &TemplateContext) -> Self {
449 let mut tera = tera::Tera::default();
450 tera.register_filter(
451 "default",
452 |value: &tera::Value,
453 kwargs: tera::Kwargs,
454 _: &tera::State|
455 -> tera::TeraResult<tera::Value> {
456 let default_val = kwargs.must_get::<tera::Value>("value")?;
457 let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
458 if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
459 Ok(default_val)
460 } else {
461 Ok(value.clone())
462 }
463 },
464 );
465 Self {
466 tera,
467 context: context.to_tera_context(),
468 next_template_id: 0,
469 }
470 }
471
472 fn render(&mut self, template: &str) -> Result<String, RenderError> {
473 if !contains_template_syntax(template) {
474 return Ok(template.to_string());
475 }
476
477 let template_name = format!("config_{}", self.next_template_id);
478 self.next_template_id += 1;
479
480 self.tera
481 .add_raw_template(&template_name, template)
482 .map_err(|e| RenderError::TemplateSyntax {
483 template: template.to_string(),
484 source: e,
485 })?;
486
487 self.tera
488 .render(&template_name, &self.context)
489 .map_err(|e| RenderError::RenderFailed {
490 template: template.to_string(),
491 source: e,
492 })
493 }
494}
495
496#[derive(Debug, thiserror::Error)]
501pub enum RenderError {
502 #[error("template syntax error in {template:?}: {source}")]
503 TemplateSyntax {
504 template: String,
505 source: tera::Error,
506 },
507 #[error("template render failed for {template:?}: {source}")]
508 RenderFailed {
509 template: String,
510 source: tera::Error,
511 },
512 #[error(
513 "{field} template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
514 )]
515 InvalidPort {
516 field: &'static str,
517 template: String,
518 rendered: String,
519 },
520}
521
522#[cfg(test)]
527mod tests {
528 use super::*;
529
530 fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
531 PitchforkTomlDaemon {
532 run: run.into(),
533 ..Default::default()
534 }
535 }
536
537 fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
538 let id = DaemonId::new("myproj", name);
539 let config = make_daemon_config("echo");
540 let mut resolved = HashMap::new();
541 resolved.insert(id.clone(), ports);
542 let mut configs = IndexMap::new();
543 configs.insert(id.clone(), make_daemon_config("echo"));
544 TemplateContext::new(
545 &DaemonId::new("myproj", "self"),
546 &config,
547 &resolved,
548 &configs,
549 )
550 }
551
552 #[test]
553 fn test_no_template_passthrough() {
554 let ctx = make_context_with_daemon("redis", vec![6379]);
555 assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
556 }
557
558 #[test]
559 fn test_self_variables() {
560 let id = DaemonId::new("myproj", "api");
561 let config = make_daemon_config("echo");
562 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
563
564 assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
565 assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
566 assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
567 }
568
569 #[test]
570 fn test_daemon_port_reference() {
571 let ctx = make_context_with_daemon("redis", vec![6379]);
572 assert_eq!(
573 render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
574 "6379"
575 );
576 }
577
578 #[test]
579 fn test_daemon_ports_array() {
580 let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
581 assert_eq!(
582 render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
583 "6379"
584 );
585 assert_eq!(
586 render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
587 "6380"
588 );
589 }
590
591 #[test]
592 fn test_daemon_qualified_name() {
593 let ctx = make_context_with_daemon("redis", vec![6379]);
594 assert_eq!(
595 render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
596 "6379"
597 );
598 }
599
600 #[test]
601 fn test_short_name_only_matches_current_namespace() {
602 let self_id = DaemonId::new("app", "api");
603 let self_config = make_daemon_config("echo");
604 let other_id = DaemonId::new("infra", "redis");
605
606 let mut resolved = HashMap::new();
607 resolved.insert(other_id.clone(), vec![6379]);
608
609 let mut configs = IndexMap::new();
610 configs.insert(other_id.clone(), make_daemon_config("echo"));
611
612 let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
613
614 assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
615 assert_eq!(
616 render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
617 "6379"
618 );
619 }
620
621 #[test]
622 fn test_settings_reference() {
623 let ctx = make_context_with_daemon("redis", vec![6379]);
624 let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
625 assert_eq!(result, "localhost");
627 }
628
629 #[test]
630 fn test_undefined_variable_error() {
631 let ctx = make_context_with_daemon("redis", vec![6379]);
632 let result = render_template("{{ nonexistent }}", &ctx);
633 assert!(result.is_err());
634 }
635
636 #[test]
637 fn test_comment_only_template_is_parsed() {
638 let ctx = make_context_with_daemon("redis", vec![6379]);
639 assert_eq!(
640 render_template("before{# hidden #}after", &ctx).unwrap(),
641 "beforeafter"
642 );
643 }
644
645 #[test]
646 fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
647 let id = DaemonId::new("myproj", "api");
648 let config = make_daemon_config("echo");
649 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
650
651 assert_eq!(
652 render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
653 "none"
654 );
655 }
656
657 #[test]
661 fn test_self_and_referenced_hosts_agree() {
662 let id = DaemonId::try_new("myproj", "api").unwrap();
663 let config = PitchforkTomlDaemon {
664 run: "server".into(),
665 port: Some(crate::config_types::PortConfig {
666 expect: vec![3000],
667 ..Default::default()
668 }),
669 ..PitchforkTomlDaemon::default()
670 };
671 let mut configs = IndexMap::new();
672 configs.insert(id.clone(), config.clone());
673 let mut resolved = HashMap::new();
674 resolved.insert(id.clone(), vec![3000]);
675
676 let ctx = TemplateContext::new(&id, &config, &resolved, &configs);
677 assert_eq!(
678 ctx.self_state.host,
679 ctx.daemon_states.get("api").unwrap().host
680 );
681 }
682
683 #[test]
684 fn test_mixed_template_and_literal() {
685 let ctx = make_context_with_daemon("redis", vec![6379]);
686 assert_eq!(
687 render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
688 "redis://localhost:6379/0"
689 );
690 }
691
692 #[test]
693 fn test_render_daemon_templates_run() {
694 let mut ctx = make_context_with_daemon("redis", vec![6379]);
695 let mut config = PitchforkTomlDaemon {
696 run: "redis-cli -p {{ daemons.redis.port }}".into(),
697 ..Default::default()
698 };
699 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
700 assert_eq!(config.run, "redis-cli -p 6379");
701 }
702
703 #[test]
704 fn test_render_daemon_templates_run_array() {
705 let mut ctx = make_context_with_daemon("redis", vec![6379]);
706 let mut config = PitchforkTomlDaemon {
707 run: crate::pitchfork_toml::RunCommand::Argv(vec![
708 "redis-cli".into(),
709 "-p".into(),
710 "{{ daemons.redis.port }}".into(),
711 "a b".into(),
712 ]),
713 ..Default::default()
714 };
715 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
716 assert_eq!(
718 config.run,
719 crate::pitchfork_toml::RunCommand::Argv(vec![
720 "redis-cli".into(),
721 "-p".into(),
722 "6379".into(),
723 "a b".into(),
724 ])
725 );
726 }
727
728 #[test]
729 fn test_render_daemon_templates_env() {
730 let mut ctx = make_context_with_daemon("redis", vec![6379]);
731 let mut config = PitchforkTomlDaemon {
732 run: "echo".into(),
733 env: Some(IndexMap::from([
734 (
735 "DATABASE_URL".to_string(),
736 "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
737 ),
738 ("STATIC_VAR".to_string(), "unchanged".to_string()),
739 ])),
740 ..Default::default()
741 };
742 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
743 let env = config.env.unwrap();
744 assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
745 assert_eq!(env["STATIC_VAR"], "unchanged");
746 }
747
748 #[test]
749 fn test_top_level_env_merged_as_defaults() {
750 let mut ctx = make_context_with_daemon("redis", vec![6379]);
751 let top_env = IndexMap::from([
752 ("GRAM_HOST".to_string(), "localhost".to_string()),
753 (
754 "GRAM_URL".to_string(),
755 "redis://{{ daemons.redis.port }}".to_string(),
756 ),
757 ]);
758 let mut config = PitchforkTomlDaemon {
759 run: "echo".into(),
760 env: Some(IndexMap::from([(
761 "GRAM_HOST".to_string(),
762 "0.0.0.0".to_string(),
763 )])),
764 ..Default::default()
765 };
766 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
767 let env = config.env.unwrap();
768 assert_eq!(env["GRAM_HOST"], "0.0.0.0");
770 assert_eq!(env["GRAM_URL"], "redis://6379");
772 }
773
774 #[test]
775 fn test_env_exposed_in_context_for_other_fields() {
776 let mut ctx = make_context_with_daemon("redis", vec![6379]);
777 let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
778 let mut config = PitchforkTomlDaemon {
779 run: "echo {{ env.GRAM_HOST }}".into(),
780 ..Default::default()
781 };
782 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
783 assert_eq!(config.run, "echo localhost");
784 }
785
786 #[test]
787 fn test_env_values_cannot_self_reference() {
788 let mut ctx = make_context_with_daemon("redis", vec![6379]);
789 let top_env = IndexMap::from([
790 ("A".to_string(), "value-a".to_string()),
791 ("B".to_string(), "{{ env.A }}".to_string()),
793 ]);
794 let mut config = PitchforkTomlDaemon {
795 run: "echo".into(),
796 ..Default::default()
797 };
798 assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
801 }
802
803 #[test]
804 fn test_render_daemon_templates_on_output_run() {
805 let mut ctx = make_context_with_daemon("redis", vec![6379]);
806 let mut config = PitchforkTomlDaemon {
807 run: "echo".into(),
808 hooks: Some(crate::config_types::PitchforkTomlHooks {
809 on_ready: None,
810 on_fail: None,
811 on_retry: None,
812 on_stop: None,
813 on_exit: None,
814 on_output: Some(crate::config_types::OnOutputHook {
815 run: "curl http://localhost:{{ daemons.redis.port }}".into(),
816 filter: Some("ready".to_string()),
817 regex: None,
818 debounce: None,
819 }),
820 }),
821 ..Default::default()
822 };
823
824 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
825
826 let hooks = config.hooks.unwrap();
827 let on_output = hooks.on_output.unwrap();
828 assert_eq!(on_output.run, "curl http://localhost:6379");
829 assert_eq!(on_output.filter.as_deref(), Some("ready"));
830 }
831
832 #[test]
833 fn test_render_daemon_templates_ready_fields() {
834 use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
835
836 let mut ctx = make_context_with_daemon("redis", vec![6379]);
837 let mut config = PitchforkTomlDaemon {
838 run: "echo".into(),
839 ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
840 ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
841 ready_http: Some(ReadyHttp {
842 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
843 status: vec![200, 401],
844 timeout: None,
845 }),
846 ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
847 ..Default::default()
848 };
849
850 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
851
852 assert_eq!(
853 config.ready_cmd.as_ref().unwrap().run,
854 "redis-cli -p 6379 ping"
855 );
856 assert_eq!(
857 config.ready_output.as_ref().unwrap().pattern,
858 "listening on 6379"
859 );
860 let http = config.ready_http.unwrap();
861 assert_eq!(http.url, "http://localhost:6379/health");
862 assert_eq!(http.status, vec![200, 401]);
863 assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
864 }
865
866 #[test]
867 fn test_render_daemon_templates_health_fields() {
868 use crate::config_types::{HealthCmd, HealthHttp};
869 use std::time::Duration;
870
871 let mut ctx = make_context_with_daemon("redis", vec![6379]);
872 let mut config = PitchforkTomlDaemon {
873 run: "echo".into(),
874 health_cmd: Some(HealthCmd {
875 run: "redis-cli -p {{ daemons.redis.port }} ping".into(),
876 interval: Some(Duration::from_secs(10)),
877 timeout: None,
878 retries: Some(3),
879 }),
880 health_http: Some(HealthHttp {
881 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
882 status: vec![200],
883 interval: None,
884 timeout: Some(Duration::from_secs(5)),
885 retries: None,
886 }),
887 ..Default::default()
888 };
889
890 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
891
892 let hc = config.health_cmd.unwrap();
893 assert_eq!(hc.run, "redis-cli -p 6379 ping");
894 assert_eq!(hc.interval, Some(Duration::from_secs(10)));
895 assert_eq!(hc.retries, Some(3));
896 let hh = config.health_http.unwrap();
897 assert_eq!(hh.url, "http://localhost:6379/health");
898 assert_eq!(hh.status, vec![200]);
899 assert_eq!(hh.timeout, Some(Duration::from_secs(5)));
900 }
901
902 #[test]
903 fn test_render_daemon_templates_ready_port_invalid() {
904 use crate::config_types::ReadyPort;
905
906 let mut ctx = make_context_with_daemon("redis", vec![6379]);
907 let mut config = PitchforkTomlDaemon {
908 run: "echo".into(),
909 ready_port: Some(ReadyPort::from_template("{{ name }}")),
910 ..Default::default()
911 };
912
913 let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
914 assert!(matches!(err, RenderError::InvalidPort { .. }));
915 }
916
917 #[test]
918 fn test_render_daemon_templates_ready_port_literal_untouched() {
919 use crate::config_types::ReadyPort;
920
921 let mut ctx = make_context_with_daemon("redis", vec![6379]);
922 let mut config = PitchforkTomlDaemon {
923 run: "echo".into(),
924 ready_port: Some(ReadyPort::new(8080)),
925 ..Default::default()
926 };
927
928 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
929 assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
930 }
931
932 #[test]
933 fn test_render_daemon_templates_health_port() {
934 use crate::config_types::HealthPort;
935 use std::time::Duration;
936
937 let mut ctx = make_context_with_daemon("redis", vec![6379]);
938 let mut config = PitchforkTomlDaemon {
939 run: "echo".into(),
940 health_port: Some(HealthPort {
941 port: None,
942 template: Some("{{ daemons.redis.port }}".to_string()),
943 interval: Some(Duration::from_secs(10)),
944 retries: Some(3),
945 timeout: Some(Duration::from_secs(5)),
946 }),
947 ..Default::default()
948 };
949
950 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
951 let hp = config.health_port.unwrap();
952 assert_eq!(hp.port, Some(6379));
953 assert!(hp.template.is_none());
954 assert_eq!(hp.interval, Some(Duration::from_secs(10)));
955 assert_eq!(hp.retries, Some(3));
956 assert_eq!(hp.timeout, Some(Duration::from_secs(5)));
957 }
958
959 #[test]
960 fn test_render_daemon_templates_health_port_invalid() {
961 use crate::config_types::HealthPort;
962
963 let mut ctx = make_context_with_daemon("redis", vec![6379]);
964 let mut config = PitchforkTomlDaemon {
965 run: "echo".into(),
966 health_port: Some(HealthPort::from_template("{{ name }}")),
967 ..Default::default()
968 };
969
970 let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
971 assert!(matches!(err, RenderError::InvalidPort { .. }));
972 }
973
974 #[test]
975 fn test_render_daemon_templates_health_port_literal_untouched() {
976 use crate::config_types::HealthPort;
977
978 let mut ctx = make_context_with_daemon("redis", vec![6379]);
979 let mut config = PitchforkTomlDaemon {
980 run: "echo".into(),
981 health_port: Some(HealthPort::new(8080)),
982 ..Default::default()
983 };
984
985 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
986 assert_eq!(config.health_port, Some(HealthPort::new(8080)));
987 }
988
989 #[test]
990 fn test_render_daemon_templates_hook_error_does_not_fail() {
991 let mut ctx = make_context_with_daemon("redis", vec![6379]);
992 let mut config = PitchforkTomlDaemon {
993 run: "echo".into(),
994 hooks: Some(crate::config_types::PitchforkTomlHooks {
995 on_ready: Some("{{ nonexistent }}".to_string()),
996 on_fail: None,
997 on_retry: None,
998 on_stop: None,
999 on_exit: None,
1000 on_output: None,
1001 }),
1002 ..Default::default()
1003 };
1004
1005 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1007 let hooks = config.hooks.unwrap();
1008 assert!(hooks.on_ready.is_none());
1009 }
1010
1011 #[test]
1012 fn test_render_daemon_templates_run_error_still_fails() {
1013 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1014 let mut config = PitchforkTomlDaemon {
1015 run: "{{ nonexistent }}".into(),
1016 ..Default::default()
1017 };
1018
1019 assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
1021 }
1022}