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 for (key, value) in self.to_json_map() {
151 ctx.insert(key, &value);
152 }
153 ctx
154 }
155
156 fn to_json_map(&self) -> serde_json::Map<String, serde_json::Value> {
158 let mut map = serde_json::Map::new();
159 let mut insert = |key: &str, value: serde_json::Value| {
160 map.insert(key.to_string(), value);
161 };
162
163 insert("name", self.self_state.name.clone().into());
165 insert("namespace", self.self_state.namespace.clone().into());
166 insert("id", self.self_state.id.clone().into());
167 insert("slug", serde_json::json!(self.self_state.slug));
168 insert("host", serde_json::json!(self.self_state.host));
169 insert(
170 "dir",
171 self.self_state.dir.to_string_lossy().to_string().into(),
172 );
173
174 let mut daemons_map = serde_json::Map::new();
176 for (name, state) in &self.daemon_states {
177 if daemons_map.contains_key(name) {
178 continue;
179 }
180 daemons_map.insert(name.clone(), daemon_state_to_json(state));
181 }
182 insert("daemons", serde_json::Value::Object(daemons_map));
183
184 let s = settings();
186 insert(
187 "settings",
188 serde_json::json!({
189 "proxy": {
190 "enable": s.proxy.enable,
191 "tld": s.proxy.tld,
192 "port": s.proxy.port,
193 "https": s.proxy.https,
194 }
195 }),
196 );
197
198 let proxy_url = crate::proxy::build_proxy_url(self.self_state.host.as_deref(), &s);
201 insert("proxy_url", serde_json::json!(proxy_url));
202 insert("url", serde_json::json!(proxy_url));
204
205 if let Some(ref env) = self.env {
207 let env: serde_json::Map<String, serde_json::Value> = env
208 .iter()
209 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
210 .collect();
211 insert("env", serde_json::Value::Object(env));
212 }
213
214 map
215 }
216}
217
218fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
219 serde_json::json!({
220 "port": state.port(),
221 "ports": state.ports,
222 "id": state.id,
223 "name": state.name,
224 "namespace": state.namespace,
225 "slug": state.slug,
226 "host": state.host,
227 "url": crate::proxy::build_proxy_url(state.host.as_deref(), &settings()),
228 "dir": state.dir.to_string_lossy(),
229 })
230}
231
232fn qualified_key(id: &DaemonId) -> String {
235 format!("{}.{}", id.namespace(), id.name())
236}
237
238pub(crate) fn merge_env(
244 top: Option<&IndexMap<String, String>>,
245 daemon: Option<&IndexMap<String, String>>,
246) -> Option<IndexMap<String, String>> {
247 match (top, daemon) {
248 (None, None) => None,
249 (Some(t), None) => Some(t.clone()),
250 (None, Some(d)) => Some(d.clone()),
251 (Some(t), Some(d)) => {
252 let mut merged = t.clone();
253 for (k, v) in d {
254 merged.insert(k.clone(), v.clone());
255 }
256 Some(merged)
257 }
258 }
259}
260
261pub fn render_env(
269 top_env: Option<&IndexMap<String, String>>,
270 daemon_env: Option<&IndexMap<String, String>>,
271 context: &TemplateContext,
272) -> Result<Option<IndexMap<String, String>>, RenderError> {
273 let Some(merged) = merge_env(top_env, daemon_env) else {
274 return Ok(None);
275 };
276 let mut renderer = TemplateRenderer::new(context);
277 let rendered: IndexMap<String, String> = merged
278 .iter()
279 .map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
280 .collect::<Result<_, _>>()?;
281 Ok(Some(rendered))
282}
283
284pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
293 TemplateRenderer::new(context).render(template)
294}
295
296pub fn render_daemon_templates(
309 config: &mut PitchforkTomlDaemon,
310 context: &mut TemplateContext,
311 top_env: Option<&IndexMap<String, String>>,
312) -> Result<(), RenderError> {
313 render_daemon_templates_with(config, context, top_env, runs_under_mise)
314}
315
316fn render_daemon_templates_with(
319 config: &mut PitchforkTomlDaemon,
320 context: &mut TemplateContext,
321 top_env: Option<&IndexMap<String, String>>,
322 runs_under_mise: impl Fn(&PitchforkTomlDaemon) -> bool,
323) -> Result<(), RenderError> {
324 let rendered_env = render_env(top_env, config.env.as_ref(), context)?;
329 config.env = rendered_env;
330 if let Some(ref env) = config.env {
333 context.set_env(env.clone());
334 }
335
336 let mut renderer = TemplateRenderer::new(context);
337
338 let mut defers_to_mise = None;
346 let mut deferred = false;
347 config.run = config.run.try_map(|arg| match renderer.render(arg) {
348 Ok(rendered) => Ok(rendered),
349 Err(err) => {
350 if *defers_to_mise.get_or_insert_with(|| runs_under_mise(config)) {
351 deferred = true;
352 Ok(arg.to_string())
353 } else {
354 Err(err)
355 }
356 }
357 })?;
358 config.deferred_template_context =
359 deferred.then(|| serde_json::Value::Object(context.to_json_map()).to_string());
360
361 if let Some(ref hooks) = config.hooks {
362 let rendered = crate::config_types::PitchforkTomlHooks {
363 on_ready: hooks
364 .on_ready
365 .as_deref()
366 .and_then(|t| renderer.render(t).ok()),
367 on_fail: hooks
368 .on_fail
369 .as_deref()
370 .and_then(|t| renderer.render(t).ok()),
371 on_retry: hooks
372 .on_retry
373 .as_deref()
374 .and_then(|t| renderer.render(t).ok()),
375 on_stop: hooks
376 .on_stop
377 .as_deref()
378 .and_then(|t| renderer.render(t).ok()),
379 on_exit: hooks
380 .on_exit
381 .as_deref()
382 .and_then(|t| renderer.render(t).ok()),
383 on_output: hooks.on_output.as_ref().and_then(|hook| {
384 renderer
385 .render(&hook.run)
386 .ok()
387 .map(|run| crate::config_types::OnOutputHook {
388 run,
389 filter: hook.filter.clone(),
390 regex: hook.regex.clone(),
391 debounce: hook.debounce.clone(),
392 })
393 }),
394 };
395 config.hooks = Some(rendered);
396 }
397
398 if let Some(ref cmd) = config.ready_cmd {
399 config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
400 run: cmd.run.try_map(|arg| renderer.render(arg))?,
403 timeout: cmd.timeout,
404 });
405 }
406
407 if let Some(ref hc) = config.health_cmd {
408 config.health_cmd = Some(crate::pitchfork_toml::HealthCmd {
409 run: hc.run.try_map(|arg| renderer.render(arg))?,
412 interval: hc.interval,
413 timeout: hc.timeout,
414 retries: hc.retries,
415 });
416 }
417
418 if let Some(ref http) = config.health_http {
419 let mut http = http.clone();
420 http.url = renderer.render(&http.url)?;
421 config.health_http = Some(http);
422 }
423
424 if let Some(ref health_port) = config.health_port
425 && let Some(ref template) = health_port.template
426 {
427 let rendered = renderer.render(template)?;
428 let port = rendered
429 .trim()
430 .parse::<u16>()
431 .ok()
432 .filter(|&p| p > 0)
433 .ok_or_else(|| RenderError::InvalidPort {
434 field: "health_port",
435 template: template.clone(),
436 rendered: rendered.clone(),
437 })?;
438 config.health_port = Some(crate::config_types::HealthPort {
439 port: Some(port),
440 template: None,
441 interval: health_port.interval,
442 retries: health_port.retries,
443 timeout: health_port.timeout,
444 });
445 }
446
447 if let Some(ref output) = config.ready_output {
448 let pattern = renderer.render(&output.pattern)?;
449 config.ready_output = Some(crate::config_types::ReadyOutput {
450 pattern,
451 timeout: output.timeout,
452 });
453 }
454
455 if let Some(ref http) = config.ready_http {
456 let mut http = http.clone();
457 http.url = renderer.render(&http.url)?;
458 config.ready_http = Some(http);
459 }
460
461 if let Some(ref ready_port) = config.ready_port
462 && let Some(ref template) = ready_port.template
463 {
464 let rendered = renderer.render(template)?;
465 let port = rendered
466 .trim()
467 .parse::<u16>()
468 .ok()
469 .filter(|&p| p > 0)
470 .ok_or_else(|| RenderError::InvalidPort {
471 field: "ready_port",
472 template: template.clone(),
473 rendered: rendered.clone(),
474 })?;
475 config.ready_port = Some(crate::config_types::ReadyPort {
476 port: Some(port),
477 template: None,
478 timeout: ready_port.timeout,
479 });
480 }
481
482 Ok(())
483}
484
485pub const TEMPLATE_CONTEXT_ENV: &str = "PITCHFORK_TEMPLATE_CONTEXT";
488
489fn runs_under_mise(config: &PitchforkTomlDaemon) -> bool {
494 mise_enabled(config)
495}
496
497pub(crate) fn mise_enabled(config: &PitchforkTomlDaemon) -> bool {
502 config.mise.unwrap_or_else(|| {
503 let dir = crate::ipc::batch::resolve_config_base_dir(config.path.as_deref());
504 crate::settings::Settings::load_from_dir(&dir).general.mise
505 })
506}
507
508fn contains_template_syntax(template: &str) -> bool {
509 template.contains("{{") || template.contains("{%") || template.contains("{#")
510}
511
512struct TemplateRenderer {
513 tera: tera::Tera,
514 context: tera::Context,
515 next_template_id: usize,
516}
517
518impl TemplateRenderer {
519 fn new(context: &TemplateContext) -> Self {
520 let mut tera = tera::Tera::default();
521 tera.register_filter(
522 "default",
523 |value: &tera::Value,
524 kwargs: tera::Kwargs,
525 _: &tera::State|
526 -> tera::TeraResult<tera::Value> {
527 let default_val = kwargs.must_get::<tera::Value>("value")?;
528 let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
529 if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
530 Ok(default_val)
531 } else {
532 Ok(value.clone())
533 }
534 },
535 );
536 Self {
537 tera,
538 context: context.to_tera_context(),
539 next_template_id: 0,
540 }
541 }
542
543 fn add_template(&mut self, template: &str) -> Result<String, RenderError> {
544 let template_name = format!("config_{}", self.next_template_id);
545 self.next_template_id += 1;
546 self.tera
547 .add_raw_template(&template_name, template)
548 .map_err(|e| RenderError::TemplateSyntax {
549 template: template.to_string(),
550 source: e,
551 })?;
552 Ok(template_name)
553 }
554
555 fn render(&mut self, template: &str) -> Result<String, RenderError> {
556 if !contains_template_syntax(template) {
557 return Ok(template.to_string());
558 }
559
560 let template_name = self.add_template(template)?;
561
562 self.tera
563 .render(&template_name, &self.context)
564 .map_err(|e| RenderError::RenderFailed {
565 template: template.to_string(),
566 source: e,
567 })
568 }
569}
570
571#[derive(Debug, thiserror::Error)]
576pub enum RenderError {
577 #[error("template syntax error in {template:?}: {source}")]
578 TemplateSyntax {
579 template: String,
580 source: tera::Error,
581 },
582 #[error("template render failed for {template:?}: {source}")]
583 RenderFailed {
584 template: String,
585 source: tera::Error,
586 },
587 #[error(
588 "{field} template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
589 )]
590 InvalidPort {
591 field: &'static str,
592 template: String,
593 rendered: String,
594 },
595}
596
597#[cfg(test)]
602mod tests {
603 use super::*;
604
605 fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
606 PitchforkTomlDaemon {
607 run: run.into(),
608 ..Default::default()
609 }
610 }
611
612 fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
613 let id = DaemonId::new("myproj", name);
614 let config = make_daemon_config("echo");
615 let mut resolved = HashMap::new();
616 resolved.insert(id.clone(), ports);
617 let mut configs = IndexMap::new();
618 configs.insert(id.clone(), make_daemon_config("echo"));
619 TemplateContext::new(
620 &DaemonId::new("myproj", "self"),
621 &config,
622 &resolved,
623 &configs,
624 )
625 }
626
627 #[test]
628 fn test_no_template_passthrough() {
629 let ctx = make_context_with_daemon("redis", vec![6379]);
630 assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
631 }
632
633 #[test]
634 fn test_self_variables() {
635 let id = DaemonId::new("myproj", "api");
636 let config = make_daemon_config("echo");
637 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
638
639 assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
640 assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
641 assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
642 }
643
644 #[test]
645 fn test_daemon_port_reference() {
646 let ctx = make_context_with_daemon("redis", vec![6379]);
647 assert_eq!(
648 render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
649 "6379"
650 );
651 }
652
653 #[test]
654 fn test_daemon_ports_array() {
655 let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
656 assert_eq!(
657 render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
658 "6379"
659 );
660 assert_eq!(
661 render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
662 "6380"
663 );
664 }
665
666 #[test]
667 fn test_daemon_qualified_name() {
668 let ctx = make_context_with_daemon("redis", vec![6379]);
669 assert_eq!(
670 render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
671 "6379"
672 );
673 }
674
675 #[test]
676 fn test_short_name_only_matches_current_namespace() {
677 let self_id = DaemonId::new("app", "api");
678 let self_config = make_daemon_config("echo");
679 let other_id = DaemonId::new("infra", "redis");
680
681 let mut resolved = HashMap::new();
682 resolved.insert(other_id.clone(), vec![6379]);
683
684 let mut configs = IndexMap::new();
685 configs.insert(other_id.clone(), make_daemon_config("echo"));
686
687 let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
688
689 assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
690 assert_eq!(
691 render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
692 "6379"
693 );
694 }
695
696 #[test]
697 fn test_settings_reference() {
698 let ctx = make_context_with_daemon("redis", vec![6379]);
699 let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
700 assert_eq!(result, "localhost");
702 }
703
704 #[test]
705 fn test_undefined_variable_error() {
706 let ctx = make_context_with_daemon("redis", vec![6379]);
707 let result = render_template("{{ nonexistent }}", &ctx);
708 assert!(result.is_err());
709 }
710
711 #[test]
712 fn test_comment_only_template_is_parsed() {
713 let ctx = make_context_with_daemon("redis", vec![6379]);
714 assert_eq!(
715 render_template("before{# hidden #}after", &ctx).unwrap(),
716 "beforeafter"
717 );
718 }
719
720 #[test]
721 fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
722 let id = DaemonId::new("myproj", "api");
723 let config = make_daemon_config("echo");
724 let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
725
726 assert_eq!(
727 render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
728 "none"
729 );
730 }
731
732 #[test]
736 fn test_self_and_referenced_hosts_agree() {
737 let id = DaemonId::try_new("myproj", "api").unwrap();
738 let config = PitchforkTomlDaemon {
739 run: "server".into(),
740 port: Some(crate::config_types::PortConfig {
741 expect: vec![3000],
742 ..Default::default()
743 }),
744 ..PitchforkTomlDaemon::default()
745 };
746 let mut configs = IndexMap::new();
747 configs.insert(id.clone(), config.clone());
748 let mut resolved = HashMap::new();
749 resolved.insert(id.clone(), vec![3000]);
750
751 let ctx = TemplateContext::new(&id, &config, &resolved, &configs);
752 assert_eq!(
753 ctx.self_state.host,
754 ctx.daemon_states.get("api").unwrap().host
755 );
756 }
757
758 #[test]
759 fn test_mixed_template_and_literal() {
760 let ctx = make_context_with_daemon("redis", vec![6379]);
761 assert_eq!(
762 render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
763 "redis://localhost:6379/0"
764 );
765 }
766
767 #[test]
768 fn test_render_daemon_templates_run() {
769 let mut ctx = make_context_with_daemon("redis", vec![6379]);
770 let mut config = PitchforkTomlDaemon {
771 run: "redis-cli -p {{ daemons.redis.port }}".into(),
772 ..Default::default()
773 };
774 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
775 assert_eq!(config.run, "redis-cli -p 6379");
776 }
777
778 #[test]
779 fn test_run_with_unknown_variable_is_deferred_to_mise() {
780 let mut ctx = make_context_with_daemon("redis", vec![6379]);
781 let mut config = PitchforkTomlDaemon {
782 run: "redis-cli -p {{ daemons.redis.port }} {{ vars.greeting | quote }}".into(),
783 ..Default::default()
784 };
785 render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
786 assert_eq!(
788 config.run,
789 "redis-cli -p {{ daemons.redis.port }} {{ vars.greeting | quote }}"
790 );
791 let json: serde_json::Value =
792 serde_json::from_str(config.deferred_template_context.as_ref().unwrap()).unwrap();
793 assert_eq!(json["daemons"]["redis"]["port"], 6379);
794 assert_eq!(json["name"], "self");
795 }
796
797 #[test]
798 fn test_unknown_variable_is_an_error_without_mise() {
799 let mut ctx = make_context_with_daemon("redis", vec![6379]);
800 let mut config = PitchforkTomlDaemon {
801 run: "echo {{ vars.greeting }}".into(),
802 ..Default::default()
803 };
804 assert!(render_daemon_templates_with(&mut config, &mut ctx, None, |_| false).is_err());
806 assert!(config.deferred_template_context.is_none());
807 }
808
809 #[test]
810 fn test_user_declared_context_variable_is_left_alone() {
811 let mut ctx = make_context_with_daemon("redis", vec![6379]);
812 let mut config = PitchforkTomlDaemon {
813 run: "redis-cli -p {{ daemons.redis.port }}".into(),
814 env: Some(IndexMap::from([(
815 TEMPLATE_CONTEXT_ENV.to_string(),
816 "mine".to_string(),
817 )])),
818 ..Default::default()
819 };
820 render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
821 assert!(config.deferred_template_context.is_none());
823 assert_eq!(config.env.as_ref().unwrap()[TEMPLATE_CONTEXT_ENV], "mine");
824 }
825
826 #[test]
827 fn test_known_variables_are_not_deferred() {
828 let mut ctx = make_context_with_daemon("redis", vec![6379]);
829 let mut config = PitchforkTomlDaemon {
830 run: "redis-cli -p {{ daemons.redis.port }}".into(),
831 ..Default::default()
832 };
833 render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
834 assert_eq!(config.run, "redis-cli -p 6379");
835 assert!(config.deferred_template_context.is_none());
836 }
837
838 #[test]
839 fn test_missing_nested_variable_is_deferred_when_env_is_defined() {
840 let mut ctx = make_context_with_daemon("redis", vec![6379]);
841 let mut config = PitchforkTomlDaemon {
842 run: "echo {{ env.MISE_ONLY }}".into(),
843 env: Some(IndexMap::from([("OWN".to_string(), "x".to_string())])),
844 ..Default::default()
845 };
846 render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
847 assert_eq!(config.run, "echo {{ env.MISE_ONLY }}");
848 let json: serde_json::Value =
849 serde_json::from_str(config.deferred_template_context.as_ref().unwrap()).unwrap();
850 assert_eq!(json["env"]["OWN"], "x");
851 }
852
853 #[test]
854 fn test_render_daemon_templates_run_array() {
855 let mut ctx = make_context_with_daemon("redis", vec![6379]);
856 let mut config = PitchforkTomlDaemon {
857 run: crate::pitchfork_toml::RunCommand::Argv(vec![
858 "redis-cli".into(),
859 "-p".into(),
860 "{{ daemons.redis.port }}".into(),
861 "a b".into(),
862 ]),
863 ..Default::default()
864 };
865 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
866 assert_eq!(
868 config.run,
869 crate::pitchfork_toml::RunCommand::Argv(vec![
870 "redis-cli".into(),
871 "-p".into(),
872 "6379".into(),
873 "a b".into(),
874 ])
875 );
876 }
877
878 #[test]
879 fn test_render_daemon_templates_env() {
880 let mut ctx = make_context_with_daemon("redis", vec![6379]);
881 let mut config = PitchforkTomlDaemon {
882 run: "echo".into(),
883 env: Some(IndexMap::from([
884 (
885 "DATABASE_URL".to_string(),
886 "redis://localhost:{{ daemons.redis.port }}/0".to_string(),
887 ),
888 ("STATIC_VAR".to_string(), "unchanged".to_string()),
889 ])),
890 ..Default::default()
891 };
892 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
893 let env = config.env.unwrap();
894 assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
895 assert_eq!(env["STATIC_VAR"], "unchanged");
896 }
897
898 #[test]
899 fn test_top_level_env_merged_as_defaults() {
900 let mut ctx = make_context_with_daemon("redis", vec![6379]);
901 let top_env = IndexMap::from([
902 ("GRAM_HOST".to_string(), "localhost".to_string()),
903 (
904 "GRAM_URL".to_string(),
905 "redis://{{ daemons.redis.port }}".to_string(),
906 ),
907 ]);
908 let mut config = PitchforkTomlDaemon {
909 run: "echo".into(),
910 env: Some(IndexMap::from([(
911 "GRAM_HOST".to_string(),
912 "0.0.0.0".to_string(),
913 )])),
914 ..Default::default()
915 };
916 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
917 let env = config.env.unwrap();
918 assert_eq!(env["GRAM_HOST"], "0.0.0.0");
920 assert_eq!(env["GRAM_URL"], "redis://6379");
922 }
923
924 #[test]
925 fn test_env_exposed_in_context_for_other_fields() {
926 let mut ctx = make_context_with_daemon("redis", vec![6379]);
927 let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
928 let mut config = PitchforkTomlDaemon {
929 run: "echo {{ env.GRAM_HOST }}".into(),
930 ..Default::default()
931 };
932 render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
933 assert_eq!(config.run, "echo localhost");
934 }
935
936 #[test]
937 fn test_env_values_cannot_self_reference() {
938 let mut ctx = make_context_with_daemon("redis", vec![6379]);
939 let top_env = IndexMap::from([
940 ("A".to_string(), "value-a".to_string()),
941 ("B".to_string(), "{{ env.A }}".to_string()),
943 ]);
944 let mut config = PitchforkTomlDaemon {
945 run: "echo".into(),
946 ..Default::default()
947 };
948 assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
951 }
952
953 #[test]
954 fn test_render_daemon_templates_on_output_run() {
955 let mut ctx = make_context_with_daemon("redis", vec![6379]);
956 let mut config = PitchforkTomlDaemon {
957 run: "echo".into(),
958 hooks: Some(crate::config_types::PitchforkTomlHooks {
959 on_ready: None,
960 on_fail: None,
961 on_retry: None,
962 on_stop: None,
963 on_exit: None,
964 on_output: Some(crate::config_types::OnOutputHook {
965 run: "curl http://localhost:{{ daemons.redis.port }}".into(),
966 filter: Some("ready".to_string()),
967 regex: None,
968 debounce: None,
969 }),
970 }),
971 ..Default::default()
972 };
973
974 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
975
976 let hooks = config.hooks.unwrap();
977 let on_output = hooks.on_output.unwrap();
978 assert_eq!(on_output.run, "curl http://localhost:6379");
979 assert_eq!(on_output.filter.as_deref(), Some("ready"));
980 }
981
982 #[test]
983 fn test_render_daemon_templates_ready_cmd_array_per_argument() {
984 use crate::config_types::ReadyCmd;
985 use crate::pitchfork_toml::RunCommand;
986
987 let mut ctx = make_context_with_daemon("redis", vec![6379]);
988 let mut config = PitchforkTomlDaemon {
989 run: "echo".into(),
990 ready_cmd: Some(ReadyCmd::new(vec![
991 "redis-cli".to_string(),
992 "-p".to_string(),
993 "{{ daemons.redis.port }}".to_string(),
994 "ping {{ daemons.redis.port }}".to_string(),
995 ])),
996 ..Default::default()
997 };
998
999 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1000
1001 assert_eq!(
1004 config.ready_cmd.unwrap().run,
1005 RunCommand::Argv(vec![
1006 "redis-cli".to_string(),
1007 "-p".to_string(),
1008 "6379".to_string(),
1009 "ping 6379".to_string(),
1010 ])
1011 );
1012 }
1013
1014 #[test]
1015 fn test_render_daemon_templates_health_cmd_array_per_argument() {
1016 use crate::config_types::HealthCmd;
1017 use crate::pitchfork_toml::RunCommand;
1018
1019 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1020 let mut config = PitchforkTomlDaemon {
1021 run: "echo".into(),
1022 health_cmd: Some(HealthCmd {
1023 run: RunCommand::Argv(vec![
1024 "redis-cli".to_string(),
1025 "-p".to_string(),
1026 "{{ daemons.redis.port }}".to_string(),
1027 "ping {{ daemons.redis.port }}".to_string(),
1028 ]),
1029 interval: Some(std::time::Duration::from_secs(5)),
1030 ..HealthCmd::default()
1031 }),
1032 ..Default::default()
1033 };
1034
1035 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1036
1037 let health_cmd = config.health_cmd.unwrap();
1038 assert_eq!(
1039 health_cmd.run,
1040 RunCommand::Argv(vec![
1041 "redis-cli".to_string(),
1042 "-p".to_string(),
1043 "6379".to_string(),
1044 "ping 6379".to_string(),
1045 ])
1046 );
1047 assert_eq!(health_cmd.interval, Some(std::time::Duration::from_secs(5)));
1048 }
1049
1050 #[test]
1051 fn test_render_daemon_templates_ready_fields() {
1052 use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
1053
1054 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1055 let mut config = PitchforkTomlDaemon {
1056 run: "echo".into(),
1057 ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
1058 ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
1059 ready_http: Some(ReadyHttp {
1060 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
1061 status: vec![200, 401],
1062 timeout: None,
1063 }),
1064 ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
1065 ..Default::default()
1066 };
1067
1068 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1069
1070 assert_eq!(
1071 config.ready_cmd.as_ref().unwrap().run,
1072 "redis-cli -p 6379 ping"
1073 );
1074 assert_eq!(
1075 config.ready_output.as_ref().unwrap().pattern,
1076 "listening on 6379"
1077 );
1078 let http = config.ready_http.unwrap();
1079 assert_eq!(http.url, "http://localhost:6379/health");
1080 assert_eq!(http.status, vec![200, 401]);
1081 assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
1082 }
1083
1084 #[test]
1085 fn test_render_daemon_templates_health_fields() {
1086 use crate::config_types::{HealthCmd, HealthHttp};
1087 use std::time::Duration;
1088
1089 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1090 let mut config = PitchforkTomlDaemon {
1091 run: "echo".into(),
1092 health_cmd: Some(HealthCmd {
1093 run: "redis-cli -p {{ daemons.redis.port }} ping".into(),
1094 interval: Some(Duration::from_secs(10)),
1095 timeout: None,
1096 retries: Some(3),
1097 }),
1098 health_http: Some(HealthHttp {
1099 url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
1100 status: vec![200],
1101 interval: None,
1102 timeout: Some(Duration::from_secs(5)),
1103 retries: None,
1104 }),
1105 ..Default::default()
1106 };
1107
1108 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1109
1110 let hc = config.health_cmd.unwrap();
1111 assert_eq!(hc.run, "redis-cli -p 6379 ping");
1112 assert_eq!(hc.interval, Some(Duration::from_secs(10)));
1113 assert_eq!(hc.retries, Some(3));
1114 let hh = config.health_http.unwrap();
1115 assert_eq!(hh.url, "http://localhost:6379/health");
1116 assert_eq!(hh.status, vec![200]);
1117 assert_eq!(hh.timeout, Some(Duration::from_secs(5)));
1118 }
1119
1120 #[test]
1121 fn test_render_daemon_templates_ready_port_invalid() {
1122 use crate::config_types::ReadyPort;
1123
1124 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1125 let mut config = PitchforkTomlDaemon {
1126 run: "echo".into(),
1127 ready_port: Some(ReadyPort::from_template("{{ name }}")),
1128 ..Default::default()
1129 };
1130
1131 let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
1132 assert!(matches!(err, RenderError::InvalidPort { .. }));
1133 }
1134
1135 #[test]
1136 fn test_render_daemon_templates_ready_port_literal_untouched() {
1137 use crate::config_types::ReadyPort;
1138
1139 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1140 let mut config = PitchforkTomlDaemon {
1141 run: "echo".into(),
1142 ready_port: Some(ReadyPort::new(8080)),
1143 ..Default::default()
1144 };
1145
1146 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1147 assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
1148 }
1149
1150 #[test]
1151 fn test_render_daemon_templates_health_port() {
1152 use crate::config_types::HealthPort;
1153 use std::time::Duration;
1154
1155 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1156 let mut config = PitchforkTomlDaemon {
1157 run: "echo".into(),
1158 health_port: Some(HealthPort {
1159 port: None,
1160 template: Some("{{ daemons.redis.port }}".to_string()),
1161 interval: Some(Duration::from_secs(10)),
1162 retries: Some(3),
1163 timeout: Some(Duration::from_secs(5)),
1164 }),
1165 ..Default::default()
1166 };
1167
1168 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1169 let hp = config.health_port.unwrap();
1170 assert_eq!(hp.port, Some(6379));
1171 assert!(hp.template.is_none());
1172 assert_eq!(hp.interval, Some(Duration::from_secs(10)));
1173 assert_eq!(hp.retries, Some(3));
1174 assert_eq!(hp.timeout, Some(Duration::from_secs(5)));
1175 }
1176
1177 #[test]
1178 fn test_render_daemon_templates_health_port_invalid() {
1179 use crate::config_types::HealthPort;
1180
1181 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1182 let mut config = PitchforkTomlDaemon {
1183 run: "echo".into(),
1184 health_port: Some(HealthPort::from_template("{{ name }}")),
1185 ..Default::default()
1186 };
1187
1188 let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
1189 assert!(matches!(err, RenderError::InvalidPort { .. }));
1190 }
1191
1192 #[test]
1193 fn test_render_daemon_templates_health_port_literal_untouched() {
1194 use crate::config_types::HealthPort;
1195
1196 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1197 let mut config = PitchforkTomlDaemon {
1198 run: "echo".into(),
1199 health_port: Some(HealthPort::new(8080)),
1200 ..Default::default()
1201 };
1202
1203 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1204 assert_eq!(config.health_port, Some(HealthPort::new(8080)));
1205 }
1206
1207 #[test]
1208 fn test_render_daemon_templates_hook_error_does_not_fail() {
1209 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1210 let mut config = PitchforkTomlDaemon {
1211 run: "echo".into(),
1212 hooks: Some(crate::config_types::PitchforkTomlHooks {
1213 on_ready: Some("{{ nonexistent }}".to_string()),
1214 on_fail: None,
1215 on_retry: None,
1216 on_stop: None,
1217 on_exit: None,
1218 on_output: None,
1219 }),
1220 ..Default::default()
1221 };
1222
1223 render_daemon_templates(&mut config, &mut ctx, None).unwrap();
1225 let hooks = config.hooks.unwrap();
1226 assert!(hooks.on_ready.is_none());
1227 }
1228
1229 #[test]
1230 fn test_render_daemon_templates_run_error_still_fails() {
1231 let mut ctx = make_context_with_daemon("redis", vec![6379]);
1232 let mut config = PitchforkTomlDaemon {
1233 run: "{{ nonexistent }}".into(),
1234 ..Default::default()
1235 };
1236
1237 assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
1239 }
1240}