use crate::daemon_id::DaemonId;
use crate::pitchfork_toml::PitchforkTomlDaemon;
use crate::settings::settings;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct DaemonTemplateState {
pub ports: Vec<u16>,
pub id: String,
pub name: String,
pub namespace: String,
pub slug: Option<String>,
pub host: Option<String>,
pub dir: PathBuf,
}
impl DaemonTemplateState {
fn port(&self) -> Option<u16> {
self.ports.first().copied()
}
}
pub struct TemplateContext {
self_state: DaemonTemplateState,
daemon_states: HashMap<String, DaemonTemplateState>,
env: Option<IndexMap<String, String>>,
}
impl TemplateContext {
pub fn new(
id: &DaemonId,
daemon_config: &PitchforkTomlDaemon,
resolved_daemons: &HashMap<DaemonId, Vec<u16>>,
daemon_configs: &IndexMap<DaemonId, PitchforkTomlDaemon>,
) -> Self {
let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
let proxy_enabled = settings().proxy.enable;
let host_of = |id: &DaemonId, config: &PitchforkTomlDaemon| {
proxy_enabled
.then(|| crate::proxy::hostname::host_for_daemon(id, Some(config), &global_slugs))
.flatten()
};
let effective_user = daemon_config.effective_user();
let dir = crate::ipc::batch::resolve_daemon_dir(
daemon_config.dir.as_deref(),
daemon_config.path.as_deref(),
effective_user.as_deref(),
);
let self_state = DaemonTemplateState {
ports: Vec::new(),
id: id.qualified(),
name: id.name().to_string(),
namespace: id.namespace().to_string(),
slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
id,
&global_slugs,
),
host: host_of(id, daemon_config),
dir,
};
let mut daemon_states = HashMap::new();
for (dep_id, ports) in resolved_daemons {
if let Some(config) = daemon_configs.get(dep_id) {
let dep_effective_user = config.effective_user();
let dep_dir = crate::ipc::batch::resolve_daemon_dir(
config.dir.as_deref(),
config.path.as_deref(),
dep_effective_user.as_deref(),
);
let state = DaemonTemplateState {
ports: ports.clone(),
id: dep_id.qualified(),
name: dep_id.name().to_string(),
namespace: dep_id.namespace().to_string(),
slug: crate::pitchfork_toml::PitchforkToml::find_slug_for_daemon_in_registry(
dep_id,
&global_slugs,
),
host: host_of(dep_id, config),
dir: dep_dir,
};
if dep_id.namespace() == id.namespace() {
daemon_states.insert(dep_id.name().to_string(), state.clone());
}
daemon_states.insert(qualified_key(dep_id), state);
}
}
Self {
self_state,
daemon_states,
env: None,
}
}
pub fn set_env(&mut self, env: IndexMap<String, String>) {
self.env = Some(env);
}
pub fn to_tera_context(&self) -> tera::Context {
let mut ctx = tera::Context::new();
for (key, value) in self.to_json_map() {
ctx.insert(key, &value);
}
ctx
}
fn to_json_map(&self) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
let mut insert = |key: &str, value: serde_json::Value| {
map.insert(key.to_string(), value);
};
insert("name", self.self_state.name.clone().into());
insert("namespace", self.self_state.namespace.clone().into());
insert("id", self.self_state.id.clone().into());
insert("slug", serde_json::json!(self.self_state.slug));
insert("host", serde_json::json!(self.self_state.host));
insert(
"dir",
self.self_state.dir.to_string_lossy().to_string().into(),
);
let mut daemons_map = serde_json::Map::new();
for (name, state) in &self.daemon_states {
if daemons_map.contains_key(name) {
continue;
}
daemons_map.insert(name.clone(), daemon_state_to_json(state));
}
insert("daemons", serde_json::Value::Object(daemons_map));
let s = settings();
insert(
"settings",
serde_json::json!({
"proxy": {
"enable": s.proxy.enable,
"tld": s.proxy.tld,
"port": s.proxy.port,
"https": s.proxy.https,
}
}),
);
let proxy_url = crate::proxy::build_proxy_url(self.self_state.host.as_deref(), &s);
insert("proxy_url", serde_json::json!(proxy_url));
insert("url", serde_json::json!(proxy_url));
if let Some(ref env) = self.env {
let env: serde_json::Map<String, serde_json::Value> = env
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect();
insert("env", serde_json::Value::Object(env));
}
map
}
}
fn daemon_state_to_json(state: &DaemonTemplateState) -> serde_json::Value {
serde_json::json!({
"port": state.port(),
"ports": state.ports,
"id": state.id,
"name": state.name,
"namespace": state.namespace,
"slug": state.slug,
"host": state.host,
"url": crate::proxy::build_proxy_url(state.host.as_deref(), &settings()),
"dir": state.dir.to_string_lossy(),
})
}
fn qualified_key(id: &DaemonId) -> String {
format!("{}.{}", id.namespace(), id.name())
}
pub(crate) fn merge_env(
top: Option<&IndexMap<String, String>>,
daemon: Option<&IndexMap<String, String>>,
) -> Option<IndexMap<String, String>> {
match (top, daemon) {
(None, None) => None,
(Some(t), None) => Some(t.clone()),
(None, Some(d)) => Some(d.clone()),
(Some(t), Some(d)) => {
let mut merged = t.clone();
for (k, v) in d {
merged.insert(k.clone(), v.clone());
}
Some(merged)
}
}
}
pub fn daemon_env_with_files(
daemon: &PitchforkTomlDaemon,
) -> Result<Option<IndexMap<String, String>>, RenderError> {
if daemon.env_file.is_empty() {
return Ok(daemon.env.clone());
}
let base = daemon
.path
.as_deref()
.and_then(crate::pitchfork_toml::project_dir_for_config);
let inline = daemon.env.clone().unwrap_or_default();
let user = daemon.effective_user();
let mut merged = IndexMap::new();
for file in &daemon.env_file {
let expanded = crate::env::expand_tilde_for_user(file, user.as_deref());
let path = match base.as_deref() {
Some(base) => base.join(expanded),
None => expanded,
};
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(RenderError::EnvFile {
path,
message: e.to_string(),
});
}
};
let outer = merged
.iter()
.chain(inline.iter())
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<Vec<_>>();
let vars = mise_dotenv::parse(
content.strip_prefix('\u{feff}').unwrap_or(&content),
true,
outer,
)
.map_err(|e| RenderError::EnvFile {
path: path.clone(),
message: e.to_string(),
})?;
merged.extend(vars);
}
merged.extend(inline);
Ok(Some(merged))
}
pub fn render_env(
top_env: Option<&IndexMap<String, String>>,
daemon_env: Option<&IndexMap<String, String>>,
context: &TemplateContext,
) -> Result<Option<IndexMap<String, String>>, RenderError> {
let Some(merged) = merge_env(top_env, daemon_env) else {
return Ok(None);
};
let mut renderer = TemplateRenderer::new(context);
let rendered: IndexMap<String, String> = merged
.iter()
.map(|(k, v)| Ok((k.clone(), renderer.render(v)?)))
.collect::<Result<_, _>>()?;
Ok(Some(rendered))
}
pub fn render_template(template: &str, context: &TemplateContext) -> Result<String, RenderError> {
TemplateRenderer::new(context).render(template)
}
pub fn render_daemon_templates(
config: &mut PitchforkTomlDaemon,
context: &mut TemplateContext,
top_env: Option<&IndexMap<String, String>>,
) -> Result<(), RenderError> {
render_daemon_templates_with(config, context, top_env, runs_under_mise)
}
fn render_daemon_templates_with(
config: &mut PitchforkTomlDaemon,
context: &mut TemplateContext,
top_env: Option<&IndexMap<String, String>>,
runs_under_mise: impl Fn(&PitchforkTomlDaemon) -> bool,
) -> Result<(), RenderError> {
let daemon_env = daemon_env_with_files(config)?;
let rendered_env = render_env(top_env, daemon_env.as_ref(), context)?;
config.env = rendered_env;
if let Some(ref env) = config.env {
context.set_env(env.clone());
}
let mut renderer = TemplateRenderer::new(context);
let mut defers_to_mise = None;
let mut deferred = false;
config.run = config.run.try_map(|arg| match renderer.render(arg) {
Ok(rendered) => Ok(rendered),
Err(err) => {
if *defers_to_mise.get_or_insert_with(|| runs_under_mise(config)) {
deferred = true;
Ok(arg.to_string())
} else {
Err(err)
}
}
})?;
config.deferred_template_context =
deferred.then(|| serde_json::Value::Object(context.to_json_map()).to_string());
if let Some(ref hooks) = config.hooks {
let rendered = crate::config_types::PitchforkTomlHooks {
on_ready: hooks
.on_ready
.as_deref()
.and_then(|t| renderer.render(t).ok()),
on_fail: hooks
.on_fail
.as_deref()
.and_then(|t| renderer.render(t).ok()),
on_retry: hooks
.on_retry
.as_deref()
.and_then(|t| renderer.render(t).ok()),
on_stop: hooks
.on_stop
.as_deref()
.and_then(|t| renderer.render(t).ok()),
on_exit: hooks
.on_exit
.as_deref()
.and_then(|t| renderer.render(t).ok()),
on_output: hooks.on_output.as_ref().and_then(|hook| {
renderer
.render(&hook.run)
.ok()
.map(|run| crate::config_types::OnOutputHook {
run,
filter: hook.filter.clone(),
regex: hook.regex.clone(),
debounce: hook.debounce.clone(),
})
}),
};
config.hooks = Some(rendered);
}
if let Some(ref cmd) = config.ready_cmd {
config.ready_cmd = Some(crate::pitchfork_toml::ReadyCmd {
run: cmd.run.try_map(|arg| renderer.render(arg))?,
timeout: cmd.timeout,
});
}
if let Some(ref hc) = config.health_cmd {
config.health_cmd = Some(crate::pitchfork_toml::HealthCmd {
run: hc.run.try_map(|arg| renderer.render(arg))?,
interval: hc.interval,
timeout: hc.timeout,
retries: hc.retries,
});
}
if let Some(ref http) = config.health_http {
let mut http = http.clone();
http.url = renderer.render(&http.url)?;
config.health_http = Some(http);
}
if let Some(ref health_port) = config.health_port
&& let Some(ref template) = health_port.template
{
let rendered = renderer.render(template)?;
let port = rendered
.trim()
.parse::<u16>()
.ok()
.filter(|&p| p > 0)
.ok_or_else(|| RenderError::InvalidPort {
field: "health_port",
template: template.clone(),
rendered: rendered.clone(),
})?;
config.health_port = Some(crate::config_types::HealthPort {
port: Some(port),
template: None,
interval: health_port.interval,
retries: health_port.retries,
timeout: health_port.timeout,
});
}
if let Some(ref output) = config.ready_output {
let pattern = renderer.render(&output.pattern)?;
config.ready_output = Some(crate::config_types::ReadyOutput {
pattern,
timeout: output.timeout,
});
}
if let Some(ref http) = config.ready_http {
let mut http = http.clone();
http.url = renderer.render(&http.url)?;
config.ready_http = Some(http);
}
if let Some(ref ready_port) = config.ready_port
&& let Some(ref template) = ready_port.template
{
let rendered = renderer.render(template)?;
let port = rendered
.trim()
.parse::<u16>()
.ok()
.filter(|&p| p > 0)
.ok_or_else(|| RenderError::InvalidPort {
field: "ready_port",
template: template.clone(),
rendered: rendered.clone(),
})?;
config.ready_port = Some(crate::config_types::ReadyPort {
port: Some(port),
template: None,
timeout: ready_port.timeout,
});
}
Ok(())
}
pub const TEMPLATE_CONTEXT_ENV: &str = "PITCHFORK_TEMPLATE_CONTEXT";
fn runs_under_mise(config: &PitchforkTomlDaemon) -> bool {
mise_enabled(config)
}
pub(crate) fn mise_enabled(config: &PitchforkTomlDaemon) -> bool {
config.mise.unwrap_or_else(|| {
let dir = crate::ipc::batch::resolve_config_base_dir(config.path.as_deref());
crate::settings::Settings::load_from_dir(&dir).general.mise
})
}
fn contains_template_syntax(template: &str) -> bool {
template.contains("{{") || template.contains("{%") || template.contains("{#")
}
struct TemplateRenderer {
tera: tera::Tera,
context: tera::Context,
next_template_id: usize,
}
impl TemplateRenderer {
fn new(context: &TemplateContext) -> Self {
let mut tera = tera::Tera::default();
tera.register_filter(
"default",
|value: &tera::Value,
kwargs: tera::Kwargs,
_: &tera::State|
-> tera::TeraResult<tera::Value> {
let default_val = kwargs.must_get::<tera::Value>("value")?;
let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
Ok(default_val)
} else {
Ok(value.clone())
}
},
);
Self {
tera,
context: context.to_tera_context(),
next_template_id: 0,
}
}
fn add_template(&mut self, template: &str) -> Result<String, RenderError> {
let template_name = format!("config_{}", self.next_template_id);
self.next_template_id += 1;
self.tera
.add_raw_template(&template_name, template)
.map_err(|e| RenderError::TemplateSyntax {
template: template.to_string(),
source: e,
})?;
Ok(template_name)
}
fn render(&mut self, template: &str) -> Result<String, RenderError> {
if !contains_template_syntax(template) {
return Ok(template.to_string());
}
let template_name = self.add_template(template)?;
self.tera
.render(&template_name, &self.context)
.map_err(|e| RenderError::RenderFailed {
template: template.to_string(),
source: e,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
#[error("template syntax error in {template:?}: {source}")]
TemplateSyntax {
template: String,
source: tera::Error,
},
#[error("template render failed for {template:?}: {source}")]
RenderFailed {
template: String,
source: tera::Error,
},
#[error("failed to load env_file {}: {message}", path.display())]
EnvFile {
path: std::path::PathBuf,
message: String,
},
#[error(
"{field} template {template:?} rendered to {rendered:?}, expected a port number (1-65535)"
)]
InvalidPort {
field: &'static str,
template: String,
rendered: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn make_daemon_config(run: &str) -> PitchforkTomlDaemon {
PitchforkTomlDaemon {
run: run.into(),
..Default::default()
}
}
fn make_context_with_daemon(name: &str, ports: Vec<u16>) -> TemplateContext {
let id = DaemonId::new("myproj", name);
let config = make_daemon_config("echo");
let mut resolved = HashMap::new();
resolved.insert(id.clone(), ports);
let mut configs = IndexMap::new();
configs.insert(id.clone(), make_daemon_config("echo"));
TemplateContext::new(
&DaemonId::new("myproj", "self"),
&config,
&resolved,
&configs,
)
}
#[test]
fn test_no_template_passthrough() {
let ctx = make_context_with_daemon("redis", vec![6379]);
assert_eq!(render_template("hello world", &ctx).unwrap(), "hello world");
}
#[test]
fn test_self_variables() {
let id = DaemonId::new("myproj", "api");
let config = make_daemon_config("echo");
let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
assert_eq!(render_template("{{ name }}", &ctx).unwrap(), "api");
assert_eq!(render_template("{{ namespace }}", &ctx).unwrap(), "myproj");
assert_eq!(render_template("{{ id }}", &ctx).unwrap(), "myproj/api");
}
#[test]
fn test_daemon_port_reference() {
let ctx = make_context_with_daemon("redis", vec![6379]);
assert_eq!(
render_template("{{ daemons.redis.port }}", &ctx).unwrap(),
"6379"
);
}
#[test]
fn test_daemon_ports_array() {
let ctx = make_context_with_daemon("redis", vec![6379, 6380]);
assert_eq!(
render_template("{{ daemons.redis.ports[0] }}", &ctx).unwrap(),
"6379"
);
assert_eq!(
render_template("{{ daemons.redis.ports[1] }}", &ctx).unwrap(),
"6380"
);
}
#[test]
fn test_daemon_qualified_name() {
let ctx = make_context_with_daemon("redis", vec![6379]);
assert_eq!(
render_template("{{ daemons[\"myproj.redis\"].port }}", &ctx).unwrap(),
"6379"
);
}
#[test]
fn test_short_name_only_matches_current_namespace() {
let self_id = DaemonId::new("app", "api");
let self_config = make_daemon_config("echo");
let other_id = DaemonId::new("infra", "redis");
let mut resolved = HashMap::new();
resolved.insert(other_id.clone(), vec![6379]);
let mut configs = IndexMap::new();
configs.insert(other_id.clone(), make_daemon_config("echo"));
let ctx = TemplateContext::new(&self_id, &self_config, &resolved, &configs);
assert!(render_template("{{ daemons.redis.port }}", &ctx).is_err());
assert_eq!(
render_template("{{ daemons[\"infra.redis\"].port }}", &ctx).unwrap(),
"6379"
);
}
#[test]
fn test_settings_reference() {
let ctx = make_context_with_daemon("redis", vec![6379]);
let result = render_template("{{ settings.proxy.tld }}", &ctx).unwrap();
assert_eq!(result, "localhost");
}
#[test]
fn test_undefined_variable_error() {
let ctx = make_context_with_daemon("redis", vec![6379]);
let result = render_template("{{ nonexistent }}", &ctx);
assert!(result.is_err());
}
#[test]
fn test_comment_only_template_is_parsed() {
let ctx = make_context_with_daemon("redis", vec![6379]);
assert_eq!(
render_template("before{# hidden #}after", &ctx).unwrap(),
"beforeafter"
);
}
#[test]
fn test_proxy_url_is_present_as_null_when_slug_is_missing() {
let id = DaemonId::new("myproj", "api");
let config = make_daemon_config("echo");
let ctx = TemplateContext::new(&id, &config, &HashMap::new(), &IndexMap::new());
assert_eq!(
render_template("{{ proxy_url | default(value=\"none\") }}", &ctx).unwrap(),
"none"
);
}
#[test]
fn test_self_and_referenced_hosts_agree() {
let id = DaemonId::try_new("myproj", "api").unwrap();
let config = PitchforkTomlDaemon {
run: "server".into(),
port: Some(crate::config_types::PortConfig {
expect: vec![3000],
..Default::default()
}),
..PitchforkTomlDaemon::default()
};
let mut configs = IndexMap::new();
configs.insert(id.clone(), config.clone());
let mut resolved = HashMap::new();
resolved.insert(id.clone(), vec![3000]);
let ctx = TemplateContext::new(&id, &config, &resolved, &configs);
assert_eq!(
ctx.self_state.host,
ctx.daemon_states.get("api").unwrap().host
);
}
#[test]
fn test_mixed_template_and_literal() {
let ctx = make_context_with_daemon("redis", vec![6379]);
assert_eq!(
render_template("redis://localhost:{{ daemons.redis.port }}/0", &ctx).unwrap(),
"redis://localhost:6379/0"
);
}
#[test]
fn test_render_daemon_templates_run() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "redis-cli -p {{ daemons.redis.port }}".into(),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
assert_eq!(config.run, "redis-cli -p 6379");
}
#[test]
fn test_run_with_unknown_variable_is_deferred_to_mise() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "redis-cli -p {{ daemons.redis.port }} {{ vars.greeting | quote }}".into(),
..Default::default()
};
render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
assert_eq!(
config.run,
"redis-cli -p {{ daemons.redis.port }} {{ vars.greeting | quote }}"
);
let json: serde_json::Value =
serde_json::from_str(config.deferred_template_context.as_ref().unwrap()).unwrap();
assert_eq!(json["daemons"]["redis"]["port"], 6379);
assert_eq!(json["name"], "self");
}
#[test]
fn test_unknown_variable_is_an_error_without_mise() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo {{ vars.greeting }}".into(),
..Default::default()
};
assert!(render_daemon_templates_with(&mut config, &mut ctx, None, |_| false).is_err());
assert!(config.deferred_template_context.is_none());
}
#[test]
fn test_user_declared_context_variable_is_left_alone() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "redis-cli -p {{ daemons.redis.port }}".into(),
env: Some(IndexMap::from([(
TEMPLATE_CONTEXT_ENV.to_string(),
"mine".to_string(),
)])),
..Default::default()
};
render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
assert!(config.deferred_template_context.is_none());
assert_eq!(config.env.as_ref().unwrap()[TEMPLATE_CONTEXT_ENV], "mine");
}
#[test]
fn test_known_variables_are_not_deferred() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "redis-cli -p {{ daemons.redis.port }}".into(),
..Default::default()
};
render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
assert_eq!(config.run, "redis-cli -p 6379");
assert!(config.deferred_template_context.is_none());
}
#[test]
fn test_missing_nested_variable_is_deferred_when_env_is_defined() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo {{ env.MISE_ONLY }}".into(),
env: Some(IndexMap::from([("OWN".to_string(), "x".to_string())])),
..Default::default()
};
render_daemon_templates_with(&mut config, &mut ctx, None, |_| true).unwrap();
assert_eq!(config.run, "echo {{ env.MISE_ONLY }}");
let json: serde_json::Value =
serde_json::from_str(config.deferred_template_context.as_ref().unwrap()).unwrap();
assert_eq!(json["env"]["OWN"], "x");
}
#[test]
fn test_render_daemon_templates_run_array() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: crate::pitchfork_toml::RunCommand::Argv(vec![
"redis-cli".into(),
"-p".into(),
"{{ daemons.redis.port }}".into(),
"a b".into(),
]),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
assert_eq!(
config.run,
crate::pitchfork_toml::RunCommand::Argv(vec![
"redis-cli".into(),
"-p".into(),
"6379".into(),
"a b".into(),
])
);
}
#[test]
fn test_render_daemon_templates_env() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
env: Some(IndexMap::from([
(
"DATABASE_URL".to_string(),
"redis://localhost:{{ daemons.redis.port }}/0".to_string(),
),
("STATIC_VAR".to_string(), "unchanged".to_string()),
])),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
let env = config.env.unwrap();
assert_eq!(env["DATABASE_URL"], "redis://localhost:6379/0");
assert_eq!(env["STATIC_VAR"], "unchanged");
}
#[test]
fn test_top_level_env_merged_as_defaults() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let top_env = IndexMap::from([
("GRAM_HOST".to_string(), "localhost".to_string()),
(
"GRAM_URL".to_string(),
"redis://{{ daemons.redis.port }}".to_string(),
),
]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
env: Some(IndexMap::from([(
"GRAM_HOST".to_string(),
"0.0.0.0".to_string(),
)])),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
let env = config.env.unwrap();
assert_eq!(env["GRAM_HOST"], "0.0.0.0");
assert_eq!(env["GRAM_URL"], "redis://6379");
}
#[test]
fn test_env_exposed_in_context_for_other_fields() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let top_env = IndexMap::from([("GRAM_HOST".to_string(), "localhost".to_string())]);
let mut config = PitchforkTomlDaemon {
run: "echo {{ env.GRAM_HOST }}".into(),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).unwrap();
assert_eq!(config.run, "echo localhost");
}
#[test]
fn test_env_values_cannot_self_reference() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let top_env = IndexMap::from([
("A".to_string(), "value-a".to_string()),
("B".to_string(), "{{ env.A }}".to_string()),
]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
..Default::default()
};
assert!(render_daemon_templates(&mut config, &mut ctx, Some(&top_env)).is_err());
}
#[test]
fn test_render_daemon_templates_on_output_run() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
hooks: Some(crate::config_types::PitchforkTomlHooks {
on_ready: None,
on_fail: None,
on_retry: None,
on_stop: None,
on_exit: None,
on_output: Some(crate::config_types::OnOutputHook {
run: "curl http://localhost:{{ daemons.redis.port }}".into(),
filter: Some("ready".to_string()),
regex: None,
debounce: None,
}),
}),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
let hooks = config.hooks.unwrap();
let on_output = hooks.on_output.unwrap();
assert_eq!(on_output.run, "curl http://localhost:6379");
assert_eq!(on_output.filter.as_deref(), Some("ready"));
}
#[test]
fn test_render_daemon_templates_ready_cmd_array_per_argument() {
use crate::config_types::ReadyCmd;
use crate::pitchfork_toml::RunCommand;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
ready_cmd: Some(ReadyCmd::new(vec![
"redis-cli".to_string(),
"-p".to_string(),
"{{ daemons.redis.port }}".to_string(),
"ping {{ daemons.redis.port }}".to_string(),
])),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
assert_eq!(
config.ready_cmd.unwrap().run,
RunCommand::Argv(vec![
"redis-cli".to_string(),
"-p".to_string(),
"6379".to_string(),
"ping 6379".to_string(),
])
);
}
#[test]
fn test_render_daemon_templates_health_cmd_array_per_argument() {
use crate::config_types::HealthCmd;
use crate::pitchfork_toml::RunCommand;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
health_cmd: Some(HealthCmd {
run: RunCommand::Argv(vec![
"redis-cli".to_string(),
"-p".to_string(),
"{{ daemons.redis.port }}".to_string(),
"ping {{ daemons.redis.port }}".to_string(),
]),
interval: Some(std::time::Duration::from_secs(5)),
..HealthCmd::default()
}),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
let health_cmd = config.health_cmd.unwrap();
assert_eq!(
health_cmd.run,
RunCommand::Argv(vec![
"redis-cli".to_string(),
"-p".to_string(),
"6379".to_string(),
"ping 6379".to_string(),
])
);
assert_eq!(health_cmd.interval, Some(std::time::Duration::from_secs(5)));
}
#[test]
fn test_render_daemon_templates_ready_fields() {
use crate::config_types::{ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort};
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
ready_cmd: Some(ReadyCmd::new("redis-cli -p {{ daemons.redis.port }} ping")),
ready_output: Some(ReadyOutput::new("listening on {{ daemons.redis.port }}")),
ready_http: Some(ReadyHttp {
url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
status: vec![200, 401],
timeout: None,
}),
ready_port: Some(ReadyPort::from_template("{{ daemons.redis.port }}")),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
assert_eq!(
config.ready_cmd.as_ref().unwrap().run,
"redis-cli -p 6379 ping"
);
assert_eq!(
config.ready_output.as_ref().unwrap().pattern,
"listening on 6379"
);
let http = config.ready_http.unwrap();
assert_eq!(http.url, "http://localhost:6379/health");
assert_eq!(http.status, vec![200, 401]);
assert_eq!(config.ready_port, Some(ReadyPort::new(6379)));
}
#[test]
fn test_render_daemon_templates_health_fields() {
use crate::config_types::{HealthCmd, HealthHttp};
use std::time::Duration;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
health_cmd: Some(HealthCmd {
run: "redis-cli -p {{ daemons.redis.port }} ping".into(),
interval: Some(Duration::from_secs(10)),
timeout: None,
retries: Some(3),
}),
health_http: Some(HealthHttp {
url: "http://localhost:{{ daemons.redis.port }}/health".to_string(),
status: vec![200],
interval: None,
timeout: Some(Duration::from_secs(5)),
retries: None,
}),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
let hc = config.health_cmd.unwrap();
assert_eq!(hc.run, "redis-cli -p 6379 ping");
assert_eq!(hc.interval, Some(Duration::from_secs(10)));
assert_eq!(hc.retries, Some(3));
let hh = config.health_http.unwrap();
assert_eq!(hh.url, "http://localhost:6379/health");
assert_eq!(hh.status, vec![200]);
assert_eq!(hh.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn test_render_daemon_templates_ready_port_invalid() {
use crate::config_types::ReadyPort;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
ready_port: Some(ReadyPort::from_template("{{ name }}")),
..Default::default()
};
let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
assert!(matches!(err, RenderError::InvalidPort { .. }));
}
#[test]
fn test_render_daemon_templates_ready_port_literal_untouched() {
use crate::config_types::ReadyPort;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
ready_port: Some(ReadyPort::new(8080)),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
assert_eq!(config.ready_port, Some(ReadyPort::new(8080)));
}
#[test]
fn test_render_daemon_templates_health_port() {
use crate::config_types::HealthPort;
use std::time::Duration;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
health_port: Some(HealthPort {
port: None,
template: Some("{{ daemons.redis.port }}".to_string()),
interval: Some(Duration::from_secs(10)),
retries: Some(3),
timeout: Some(Duration::from_secs(5)),
}),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
let hp = config.health_port.unwrap();
assert_eq!(hp.port, Some(6379));
assert!(hp.template.is_none());
assert_eq!(hp.interval, Some(Duration::from_secs(10)));
assert_eq!(hp.retries, Some(3));
assert_eq!(hp.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn test_render_daemon_templates_health_port_invalid() {
use crate::config_types::HealthPort;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
health_port: Some(HealthPort::from_template("{{ name }}")),
..Default::default()
};
let err = render_daemon_templates(&mut config, &mut ctx, None).unwrap_err();
assert!(matches!(err, RenderError::InvalidPort { .. }));
}
#[test]
fn test_render_daemon_templates_health_port_literal_untouched() {
use crate::config_types::HealthPort;
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
health_port: Some(HealthPort::new(8080)),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
assert_eq!(config.health_port, Some(HealthPort::new(8080)));
}
#[test]
fn test_render_daemon_templates_hook_error_does_not_fail() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "echo".into(),
hooks: Some(crate::config_types::PitchforkTomlHooks {
on_ready: Some("{{ nonexistent }}".to_string()),
on_fail: None,
on_retry: None,
on_stop: None,
on_exit: None,
on_output: None,
}),
..Default::default()
};
render_daemon_templates(&mut config, &mut ctx, None).unwrap();
let hooks = config.hooks.unwrap();
assert!(hooks.on_ready.is_none());
}
#[test]
fn test_render_daemon_templates_run_error_still_fails() {
let mut ctx = make_context_with_daemon("redis", vec![6379]);
let mut config = PitchforkTomlDaemon {
run: "{{ nonexistent }}".into(),
..Default::default()
};
assert!(render_daemon_templates(&mut config, &mut ctx, None).is_err());
}
#[test]
fn env_file_layers_under_inline_env_and_skips_missing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "A=file\nB=file\n").unwrap();
std::fs::write(
dir.path().join(".env.local"),
"B=local\nexport C=\"${B}-x\"\n",
)
.unwrap();
let config = PitchforkTomlDaemon {
run: "echo".into(),
env_file: vec![".env".into(), ".env.local".into(), "missing".into()],
env: Some(IndexMap::from([("A".to_string(), "inline".to_string())])),
path: Some(dir.path().join("pitchfork.toml")),
..Default::default()
};
let env = daemon_env_with_files(&config).unwrap().unwrap();
assert_eq!(env["A"], "inline");
assert_eq!(env["B"], "local");
assert_eq!(env["C"], "local-x");
}
#[test]
fn env_file_later_files_interpolate_earlier_ones() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.env"), "HOST=db\n").unwrap();
std::fs::write(dir.path().join("b.env"), "URL=pg://${HOST}/x\n").unwrap();
let config = PitchforkTomlDaemon {
run: "echo".into(),
env_file: vec!["a.env".into(), "b.env".into()],
path: Some(dir.path().join("pitchfork.toml")),
..Default::default()
};
let env = daemon_env_with_files(&config).unwrap().unwrap();
assert_eq!(env["URL"], "pg://db/x");
}
#[test]
fn env_file_substitution_prefers_inline_env() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.env"), "HOST=file\n").unwrap();
std::fs::write(dir.path().join("b.env"), "URL=pg://${HOST}\n").unwrap();
let config = PitchforkTomlDaemon {
run: "echo".into(),
env_file: vec!["a.env".into(), "b.env".into()],
env: Some(IndexMap::from([("HOST".to_string(), "inline".to_string())])),
path: Some(dir.path().join("pitchfork.toml")),
..Default::default()
};
let env = daemon_env_with_files(&config).unwrap().unwrap();
assert_eq!(env["URL"], "pg://inline");
}
#[test]
fn env_file_syntax_error_names_the_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".env"), "not valid = = =\n").unwrap();
let config = PitchforkTomlDaemon {
run: "echo".into(),
env_file: vec![".env".into()],
path: Some(dir.path().join("pitchfork.toml")),
..Default::default()
};
let err = daemon_env_with_files(&config).unwrap_err().to_string();
assert!(err.contains(".env"), "{err}");
}
}