Skip to content

Commit 05f659b

Browse files
committed
feat: add files.preprocess option to template-preprocess static monitor files, closes #187
1 parent 0d0a72b commit 05f659b

5 files changed

Lines changed: 268 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99
### Added
1010
- Add `/health` and `/metrics` (Prometheus-compatible) HTTP endpoints, with a `HEALTHCHECK` in the Dockerfile, see #164. Listens on port 8090 by default (configurable via `healthcheck_port`, set to `null` to disable)
11+
- Add `files.preprocess` config option (default `false`) to preprocess static monitor files as Tera templates before parsing, allowing use of `get_env` and other Tera functions inside JSON/TOML monitor files. The `json_escape`/`json_unescape` and `toml_escape`/`toml_unescape` filters are provided for safe value embedding, closes #187
1112

1213
### Changed
1314
- Docker container/service listing failures no longer abort the entire sync — a warning is logged and the sync continues, see #116

autokuma/src/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ pub struct FilesConfig {
209209
/// Whether the files source should follow symlinks or not.
210210
#[serde_inline_default(false)]
211211
pub follow_symlinks: bool,
212+
213+
/// Whether to preprocess static monitor files as Tera templates before parsing.
214+
/// When enabled, Tera expressions (e.g. `{{ get_env(name="MY_SECRET") }}`) are
215+
/// evaluated in the file content before JSON/TOML parsing.
216+
#[serde_inline_default(false)]
217+
pub preprocess: bool,
212218
}
213219

214220
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

autokuma/src/sources/file_source.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::{
33
entity::{get_entity_from_value, Entity},
44
error::{Error, Result},
55
sources::source::Source,
6+
util::fill_templates,
67
};
78
use async_trait::async_trait;
89
use itertools::Itertools;
@@ -28,12 +29,24 @@ async fn get_entities_from_file<P1: AsRef<Path>, P2: AsRef<Path>>(
2829
.await
2930
.map_err(|e| Error::IO(e.to_string()))?;
3031

32+
let content = if state.config.files.preprocess {
33+
fill_templates(state.config.clone(), content, &tera::Context::new())?
34+
} else {
35+
content
36+
};
37+
3138
serde_json::from_str(&content).map_err(|e| Error::DeserializeError(e.to_string()))?
3239
} else if file.extension().is_some_and(|ext| ext == "toml") {
3340
let content = tokio::fs::read_to_string(file_path)
3441
.await
3542
.map_err(|e| Error::IO(e.to_string()))?;
3643

44+
let content = if state.config.files.preprocess {
45+
fill_templates(state.config.clone(), content, &tera::Context::new())?
46+
} else {
47+
content
48+
};
49+
3750
toml::from_str(&content).map_err(|e| Error::DeserializeError(e.to_string()))?
3851
} else {
3952
return Ok(vec![]);

autokuma/src/util.rs

Lines changed: 246 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ use serde_json::json;
66
use std::error::Error as StdError;
77
use std::{collections::BTreeMap, sync::Arc};
88
use tera::Tera;
9-
109
pub fn print_error_chain(error: &dyn StdError) -> String {
1110
let mut result = "\n".to_owned();
1211
let mut current_error = Some(error);
@@ -137,6 +136,92 @@ impl tera::Function for GetEnvFunction {
137136
}
138137
}
139138

139+
struct JsonEscapeFilter;
140+
141+
impl tera::Filter for JsonEscapeFilter {
142+
fn filter(
143+
&self,
144+
value: &tera::Value,
145+
_: &std::collections::HashMap<String, tera::Value>,
146+
) -> tera::Result<tera::Value> {
147+
let Some(s) = value.as_str() else {
148+
return Ok(value.clone());
149+
};
150+
let json = serde_json::to_string(s).map_err(|e| tera::Error::msg(e.to_string()))?;
151+
Ok(tera::Value::String(json[1..json.len() - 1].to_owned()))
152+
}
153+
}
154+
155+
struct JsonUnescapeFilter;
156+
157+
impl tera::Filter for JsonUnescapeFilter {
158+
fn filter(
159+
&self,
160+
value: &tera::Value,
161+
_: &std::collections::HashMap<String, tera::Value>,
162+
) -> tera::Result<tera::Value> {
163+
let Some(s) = value.as_str() else {
164+
return Ok(value.clone());
165+
};
166+
let wrapped = format!("\"{}\"", s);
167+
let unescaped: String =
168+
serde_json::from_str(&wrapped).map_err(|e| tera::Error::msg(e.to_string()))?;
169+
Ok(tera::Value::String(unescaped))
170+
}
171+
}
172+
173+
struct TomlEscapeFilter;
174+
175+
impl tera::Filter for TomlEscapeFilter {
176+
fn filter(
177+
&self,
178+
value: &tera::Value,
179+
_: &std::collections::HashMap<String, tera::Value>,
180+
) -> tera::Result<tera::Value> {
181+
let Some(s) = value.as_str() else {
182+
return Ok(value.clone());
183+
};
184+
let mut result = String::with_capacity(s.len());
185+
for c in s.chars() {
186+
match c {
187+
'\u{0008}' => result.push_str(r"\b"),
188+
'\t' => result.push_str(r"\t"),
189+
'\n' => result.push_str(r"\n"),
190+
'\u{000C}' => result.push_str(r"\f"),
191+
'\r' => result.push_str(r"\r"),
192+
'"' => result.push_str("\\\""),
193+
'\\' => result.push_str(r"\\"),
194+
c if (c as u32) < 0x20 => result.push_str(&format!("\\u{:04X}", c as u32)),
195+
c => result.push(c),
196+
}
197+
}
198+
Ok(tera::Value::String(result))
199+
}
200+
}
201+
202+
struct TomlUnescapeFilter;
203+
204+
impl tera::Filter for TomlUnescapeFilter {
205+
fn filter(
206+
&self,
207+
value: &tera::Value,
208+
_: &std::collections::HashMap<String, tera::Value>,
209+
) -> tera::Result<tera::Value> {
210+
let Some(s) = value.as_str() else {
211+
return Ok(value.clone());
212+
};
213+
let table_str = format!("v = \"{}\"", s);
214+
let table: toml::Table =
215+
toml::from_str(&table_str).map_err(|e| tera::Error::msg(e.to_string()))?;
216+
let result = table
217+
.get("v")
218+
.and_then(|v| v.as_str())
219+
.ok_or_else(|| tera::Error::msg("toml_unescape: failed to extract value"))?
220+
.to_owned();
221+
Ok(tera::Value::String(result))
222+
}
223+
}
224+
140225
pub fn fill_templates(
141226
config: Arc<Config>,
142227
template: impl Into<String>,
@@ -149,8 +234,168 @@ pub fn fill_templates(
149234
};
150235

151236
tera.register_function("get_env", get_env);
237+
tera.register_filter("json_escape", JsonEscapeFilter);
238+
tera.register_filter("json_unescape", JsonUnescapeFilter);
239+
tera.register_filter("toml_escape", TomlEscapeFilter);
240+
tera.register_filter("toml_unescape", TomlUnescapeFilter);
152241

153242
tera.add_raw_template(&template, &template)
154243
.and_then(|_| tera.render(&template, template_values))
155244
.map_err(|e| Error::LabelParseError(print_error_chain(&e)))
156245
}
246+
247+
#[cfg(test)]
248+
mod tests {
249+
use super::*;
250+
use crate::config::Config;
251+
252+
fn test_config() -> Arc<Config> {
253+
Arc::new(
254+
serde_json::from_value(json!({
255+
"kuma": {"url": "http://localhost:3001", "tls": {}},
256+
"docker": {},
257+
"files": {},
258+
"kubernetes": {}
259+
}))
260+
.unwrap(),
261+
)
262+
}
263+
264+
fn render(template: &str, context: tera::Context) -> String {
265+
fill_templates(test_config(), template, &context).unwrap()
266+
}
267+
268+
fn ctx(key: &str, value: &str) -> tera::Context {
269+
let mut c = tera::Context::new();
270+
c.insert(key, value);
271+
c
272+
}
273+
274+
#[test]
275+
fn json_escape_escapes_double_quotes() {
276+
assert_eq!(
277+
render("{{ v | json_escape }}", ctx("v", r#"hello "world""#)),
278+
r#"hello \"world\""#
279+
);
280+
}
281+
282+
#[test]
283+
fn json_escape_escapes_newlines() {
284+
assert_eq!(
285+
render("{{ v | json_escape }}", ctx("v", "line1\nline2")),
286+
r#"line1\nline2"#
287+
);
288+
}
289+
290+
#[test]
291+
fn json_escape_escapes_backslashes() {
292+
assert_eq!(
293+
render("{{ v | json_escape }}", ctx("v", r#"back\slash"#)),
294+
r#"back\\slash"#
295+
);
296+
}
297+
298+
#[test]
299+
fn json_unescape_unescapes_double_quotes() {
300+
assert_eq!(
301+
render("{{ v | json_unescape }}", ctx("v", r#"hello \"world\""#)),
302+
r#"hello "world""#
303+
);
304+
}
305+
306+
#[test]
307+
fn json_unescape_unescapes_newlines() {
308+
assert_eq!(
309+
render("{{ v | json_unescape }}", ctx("v", r#"line1\nline2"#)),
310+
"line1\nline2"
311+
);
312+
}
313+
314+
#[test]
315+
fn json_escape_unescape_round_trip() {
316+
let raw = "password: \"p@ss/w0rd\"\nwith newline";
317+
let result = render(
318+
"{{ v | json_escape | json_unescape }}",
319+
ctx("v", raw),
320+
);
321+
assert_eq!(result, raw);
322+
}
323+
324+
#[test]
325+
fn toml_escape_escapes_double_quotes() {
326+
assert_eq!(
327+
render("{{ v | toml_escape }}", ctx("v", r#"hello "world""#)),
328+
r#"hello \"world\""#
329+
);
330+
}
331+
332+
#[test]
333+
fn toml_escape_escapes_newlines() {
334+
assert_eq!(
335+
render("{{ v | toml_escape }}", ctx("v", "line1\nline2")),
336+
r#"line1\nline2"#
337+
);
338+
}
339+
340+
#[test]
341+
fn toml_escape_escapes_backslashes() {
342+
assert_eq!(
343+
render("{{ v | toml_escape }}", ctx("v", r#"back\slash"#)),
344+
r#"back\\slash"#
345+
);
346+
}
347+
348+
#[test]
349+
fn toml_unescape_unescapes_double_quotes() {
350+
assert_eq!(
351+
render("{{ v | toml_unescape }}", ctx("v", r#"hello \"world\""#)),
352+
r#"hello "world""#
353+
);
354+
}
355+
356+
#[test]
357+
fn toml_unescape_unescapes_newlines() {
358+
assert_eq!(
359+
render("{{ v | toml_unescape }}", ctx("v", r#"line1\nline2"#)),
360+
"line1\nline2"
361+
);
362+
}
363+
364+
#[test]
365+
fn toml_escape_unescape_round_trip() {
366+
let raw = "password: \"p@ss/w0rd\"\nwith newline";
367+
let result = render(
368+
"{{ v | toml_escape | toml_unescape }}",
369+
ctx("v", raw),
370+
);
371+
assert_eq!(result, raw);
372+
}
373+
374+
#[test]
375+
fn json_escape_passes_through_non_string() {
376+
let mut c = tera::Context::new();
377+
c.insert("v", &42);
378+
assert_eq!(render("{{ v | json_escape }}", c), "42");
379+
}
380+
381+
#[test]
382+
fn json_unescape_passes_through_non_string() {
383+
let mut c = tera::Context::new();
384+
c.insert("v", &true);
385+
assert_eq!(render("{{ v | json_unescape }}", c), "true");
386+
}
387+
388+
#[test]
389+
fn toml_escape_passes_through_non_string() {
390+
let mut c = tera::Context::new();
391+
c.insert("v", &3.14f64);
392+
assert_eq!(render("{{ v | toml_escape }}", c), "3.14");
393+
}
394+
395+
#[test]
396+
fn toml_unescape_passes_through_non_string() {
397+
let mut c = tera::Context::new();
398+
c.insert("v", &false);
399+
assert_eq!(render("{{ v | toml_unescape }}", c), "false");
400+
}
401+
}

docs/autokuma/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ AutoKuma is configured through environment variables or a configuration file. Al
3838
| `AUTOKUMA__KUBERNETES__ENABLED` | `kubernetes.enabled` | Enable or disable the Kubernetes source |
3939
| `AUTOKUMA__FILES__ENABLED` | `files.enabled` | Enable or disable the Files source |
4040
| `AUTOKUMA__FILES__FOLLOW_SYMLINKS` | `files.follow_symlinks` | Follow symlinks when scanning for static monitors |
41+
| `AUTOKUMA__FILES__PREPROCESS` | `files.preprocess` | Preprocess static monitor files as Tera templates before parsing (default: `false`). Allows use of `get_env` and other Tera expressions inside `.json`/`.toml` monitor files. The following filters are available to safely embed values into file contents: `json_escape` / `json_unescape` for JSON files, `toml_escape` / `toml_unescape` for TOML files |
4142

4243
## Secret Files
4344

@@ -91,6 +92,7 @@ url = "unix:///var/run/docker.sock"
9192
[files]
9293
enabled = true
9394
follow_symlinks = false
95+
preprocess = false
9496
9597
[kubernetes]
9698
enabled = false

0 commit comments

Comments
 (0)