1use crate::config_check::Finding;
26use serde_json::Value;
27use std::collections::HashSet;
28use std::sync::OnceLock;
29
30const DESCRIPTORS: &str = include_str!("../data/stack-adapters.json");
31
32#[derive(Debug, Clone)]
33pub struct FieldSpec {
34 pub key: String,
35 pub kind: String,
37 pub secret: bool,
38 pub default: Option<String>,
39 pub choices: Vec<String>,
40 pub help: Option<String>,
41}
42
43#[derive(Debug, Clone)]
44pub struct ChunkSpec {
45 pub type_id: String,
46 pub label: String,
47 pub singleton: bool,
48 pub fields: Vec<FieldSpec>,
49}
50
51#[derive(Debug, Clone)]
52pub struct Starter {
53 pub type_id: String,
54 pub name: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct Rule {
59 pub id: &'static str,
60 pub kind: String,
61 pub type_id: String,
62 pub field: Option<String>,
63 pub fields: Vec<String>,
64 pub when: Option<String>,
65 pub values: Vec<String>,
66 pub severity: &'static str,
67 pub message: String,
68}
69
70#[derive(Debug, Clone)]
71pub struct Adapter {
72 pub id: String,
73 pub label: String,
74 pub abbr: String,
75 pub description: String,
76 pub file: String,
77 pub chunks: Vec<ChunkSpec>,
78 pub starter: Vec<Starter>,
79 pub output: Value,
80 pub rules: Vec<Rule>,
81}
82
83fn str_of(v: &Value, k: &str) -> String {
84 v.get(k).and_then(Value::as_str).unwrap_or("").to_string()
85}
86
87fn strs(v: &Value, k: &str) -> Vec<String> {
88 v.get(k)
89 .and_then(Value::as_array)
90 .map(|a| {
91 a.iter()
92 .filter_map(Value::as_str)
93 .map(String::from)
94 .collect()
95 })
96 .unwrap_or_default()
97}
98
99fn leak(s: String) -> &'static str {
102 Box::leak(s.into_boxed_str())
103}
104
105fn parse() -> Vec<Adapter> {
106 let doc: Value = serde_json::from_str(DESCRIPTORS).expect("stack-adapters.json is valid JSON");
107 doc["adapters"]
108 .as_array()
109 .expect("adapters is an array")
110 .iter()
111 .map(|a| {
112 let id = str_of(a, "id");
113 let chunks = a["chunks"]
114 .as_array()
115 .map(|cs| {
116 cs.iter()
117 .map(|c| ChunkSpec {
118 type_id: str_of(c, "type"),
119 label: str_of(c, "label"),
120 singleton: c["singleton"].as_bool().unwrap_or(false),
121 fields: c["fields"]
122 .as_array()
123 .map(|fs| {
124 fs.iter()
125 .map(|f| FieldSpec {
126 key: str_of(f, "key"),
127 kind: if f["kind"].is_string() {
128 str_of(f, "kind")
129 } else {
130 "text".into()
131 },
132 secret: f["secret"].as_bool().unwrap_or(false),
133 default: f["default"].as_str().map(String::from),
134 choices: strs(f, "choices"),
135 help: f["help"].as_str().map(String::from),
136 })
137 .collect()
138 })
139 .unwrap_or_default(),
140 })
141 .collect()
142 })
143 .unwrap_or_default();
144 let rules = a["rules"]
145 .as_array()
146 .map(|rs| {
147 rs.iter()
148 .map(|r| Rule {
149 id: leak(format!("stack-{id}-{}", str_of(r, "id"))),
150 kind: str_of(r, "kind"),
151 type_id: str_of(r, "type"),
152 field: r["field"].as_str().map(String::from),
153 fields: strs(r, "fields"),
154 when: r["when"].as_str().map(String::from),
155 values: strs(r, "values"),
156 severity: if str_of(r, "severity") == "error" {
157 "error"
158 } else {
159 "warning"
160 },
161 message: str_of(r, "message"),
162 })
163 .collect()
164 })
165 .unwrap_or_default();
166 Adapter {
167 label: str_of(a, "label"),
168 abbr: str_of(a, "abbr"),
169 description: str_of(a, "description"),
170 file: str_of(a, "file"),
171 starter: a["starter"]
172 .as_array()
173 .map(|ss| {
174 ss.iter()
175 .map(|s| Starter {
176 type_id: str_of(s, "type"),
177 name: str_of(s, "name"),
178 })
179 .collect()
180 })
181 .unwrap_or_default(),
182 output: a["output"].clone(),
183 chunks,
184 rules,
185 id,
186 }
187 })
188 .collect()
189}
190
191pub fn adapters() -> &'static [Adapter] {
192 static ALL: OnceLock<Vec<Adapter>> = OnceLock::new();
193 ALL.get_or_init(parse)
194}
195
196pub fn adapter(id: &str) -> Option<&'static Adapter> {
197 adapters().iter().find(|a| a.id == id)
198}
199
200pub fn chunk_types() -> Vec<&'static str> {
202 adapters()
203 .iter()
204 .flat_map(|a| a.chunks.iter().map(|c| c.type_id.as_str()))
205 .collect()
206}
207
208impl Adapter {
209 pub fn chunk_spec(&self, type_id: &str) -> Option<&ChunkSpec> {
210 self.chunks.iter().find(|c| c.type_id == type_id)
211 }
212}
213
214#[derive(Debug, Clone, PartialEq)]
217enum Y {
218 Str(String),
219 Bool(bool),
220 Int(i64),
221 List(Vec<Y>),
222 Map(Vec<(String, Y)>),
223}
224
225fn reserved(s: &str) -> bool {
228 matches!(
229 s.to_ascii_lowercase().as_str(),
230 "true" | "false" | "yes" | "no" | "on" | "off" | "y" | "n" | "null" | "~" | "nan" | "inf"
231 )
232}
233
234fn scalar(s: &str) -> String {
240 let bare = !s.is_empty()
241 && !reserved(s)
242 && s.chars()
243 .next()
244 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
245 && s.chars()
246 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
247 if bare {
248 s.to_string()
249 } else {
250 serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
251 }
252}
253
254fn write_map(out: &mut String, entries: &[(String, Y)], indent: usize) {
255 let pad = " ".repeat(indent);
256 for (k, v) in entries {
257 out.push_str(&pad);
258 out.push_str(&scalar(k));
259 out.push(':');
260 match v {
261 Y::Str(s) => {
262 out.push(' ');
263 out.push_str(&scalar(s));
264 out.push('\n');
265 }
266 Y::Bool(b) => out.push_str(&format!(" {b}\n")),
267 Y::Int(i) => out.push_str(&format!(" {i}\n")),
268 Y::Map(m) if m.is_empty() => out.push_str(" {}\n"),
269 Y::List(l) if l.is_empty() => out.push_str(" []\n"),
270 Y::Map(m) => {
271 out.push('\n');
272 write_map(out, m, indent + 2);
273 }
274 Y::List(l) => {
275 out.push('\n');
276 write_list(out, l, indent + 2);
277 }
278 }
279 }
280}
281
282fn write_list(out: &mut String, items: &[Y], indent: usize) {
283 let pad = " ".repeat(indent);
284 for item in items {
285 match item {
286 Y::Map(m) if !m.is_empty() => {
287 let mut inner = String::new();
289 write_map(&mut inner, m, indent + 2);
290 out.push_str(&pad);
291 out.push_str("- ");
292 out.push_str(&inner[indent + 2..]);
293 }
294 Y::List(l) if !l.is_empty() => {
295 out.push_str(&pad);
296 out.push_str("-\n");
297 write_list(out, l, indent + 2);
298 }
299 Y::Str(s) => out.push_str(&format!("{pad}- {}\n", scalar(s))),
300 Y::Bool(b) => out.push_str(&format!("{pad}- {b}\n")),
301 Y::Int(i) => out.push_str(&format!("{pad}- {i}\n")),
302 Y::Map(_) => out.push_str(&format!("{pad}- {{}}\n")),
303 Y::List(_) => out.push_str(&format!("{pad}- []\n")),
304 }
305 }
306}
307
308fn to_yaml(root: &Y) -> String {
309 let mut out = String::from("# Generated by UnENVerse\n");
310 match root {
311 Y::Map(m) => write_map(&mut out, m, 0),
312 Y::List(l) => write_list(&mut out, l, 0),
313 _ => {}
314 }
315 out
316}
317
318pub type Resolve<'a> = &'a dyn Fn(&str, bool) -> String;
322
323struct Ctx<'a> {
324 adapter: &'a Adapter,
325 chunks: &'a [&'a Value],
326 resolve: Resolve<'a>,
327 cur: Option<(&'a Value, &'a ChunkSpec)>,
329}
330
331fn chunk_type(c: &Value) -> &str {
332 c.get("chunk_type").and_then(Value::as_str).unwrap_or("")
333}
334
335fn raw_field<'a>(chunk: &'a Value, key: &str) -> &'a str {
336 if key == "@name" {
337 return chunk.get("name").and_then(Value::as_str).unwrap_or("");
338 }
339 chunk
340 .get("fields")
341 .and_then(Value::as_array)
342 .and_then(|fs| {
343 fs.iter()
344 .find(|f| f.get("key").and_then(Value::as_str) == Some(key))
345 })
346 .and_then(|f| f.get("value"))
347 .and_then(Value::as_str)
348 .unwrap_or("")
349}
350
351fn is_secret(spec: &ChunkSpec, key: &str) -> bool {
352 spec.fields.iter().any(|f| f.key == key && f.secret)
353}
354
355impl Ctx<'_> {
356 fn field_text(&self, key: &str) -> String {
357 let Some((chunk, spec)) = self.cur else {
358 return String::new();
359 };
360 let raw = raw_field(chunk, key);
361 if raw.trim().is_empty() {
362 return String::new();
363 }
364 if key == "@name" {
365 return raw.to_string();
366 }
367 (self.resolve)(raw, is_secret(spec, key))
368 }
369
370 fn of_type(&self, type_id: &str) -> Vec<&Value> {
371 self.chunks
372 .iter()
373 .copied()
374 .filter(|c| chunk_type(c) == type_id)
375 .collect()
376 }
377
378 fn with<'b>(&'b self, chunk: &'b Value, spec: &'b ChunkSpec) -> Ctx<'b> {
379 Ctx {
380 adapter: self.adapter,
381 chunks: self.chunks,
382 resolve: self.resolve,
383 cur: Some((chunk, spec)),
384 }
385 }
386
387 fn each_chunk(&self, type_id: &str) -> Vec<Ctx<'_>> {
388 let Some(spec) = self.adapter.chunk_spec(type_id) else {
389 return Vec::new();
390 };
391 self.of_type(type_id)
392 .into_iter()
393 .map(|c| self.with(c, spec))
394 .collect()
395 }
396}
397
398fn split_list(s: &str) -> Vec<String> {
399 s.split(['\n', ','])
400 .map(str::trim)
401 .filter(|x| !x.is_empty())
402 .map(String::from)
403 .collect()
404}
405
406fn truthy(s: &str) -> Option<bool> {
407 match s.trim().to_ascii_lowercase().as_str() {
408 "true" | "yes" | "on" | "1" => Some(true),
409 "false" | "no" | "off" | "0" => Some(false),
410 _ => None,
411 }
412}
413
414fn keep(node: &Value) -> bool {
415 node.get("keep").and_then(Value::as_bool).unwrap_or(false)
416}
417
418fn eval(node: &Value, ctx: &Ctx) -> Option<Y> {
419 match node {
420 Value::String(s) => Some(Y::Str(s.clone())),
421 Value::Bool(b) => Some(Y::Bool(*b)),
422 Value::Number(n) => n.as_i64().map(Y::Int),
423 Value::Object(o) => {
424 if let Some(f) = o.get("f").and_then(Value::as_str) {
425 let text = ctx.field_text(f);
426 if text.is_empty() {
427 return None;
428 }
429 return match o.get("as").and_then(Value::as_str).unwrap_or("str") {
430 "list" => {
431 let items = split_list(&text);
432 (!items.is_empty())
433 .then(|| Y::List(items.into_iter().map(Y::Str).collect()))
434 }
435 "bool" => truthy(&text).map(Y::Bool),
436 "int" => Some(text.trim().parse::<i64>().map_or(Y::Str(text), Y::Int)),
437 _ => Some(Y::Str(match o.get("fmt").and_then(Value::as_str) {
438 Some(fmt) => fmt.replace("{}", &text),
439 None => text,
440 })),
441 };
442 }
443 if let Some(pairs) = o.get("map").and_then(Value::as_array) {
444 let entries: Vec<(String, Y)> = pairs
445 .iter()
446 .filter_map(|p| {
447 let p = p.as_array()?;
448 let key = p.first()?.as_str()?.to_string();
449 Some((key, eval(p.get(1)?, ctx)?))
450 })
451 .collect();
452 return (!entries.is_empty() || keep(node)).then_some(Y::Map(entries));
453 }
454 if let Some(items) = o.get("list").and_then(Value::as_array) {
455 let l: Vec<Y> = items.iter().filter_map(|n| eval(n, ctx)).collect();
456 return (!l.is_empty() || keep(node)).then_some(Y::List(l));
457 }
458 if let Some(t) = o.get("each").and_then(Value::as_str) {
459 let inner = o.get("node")?;
460 let l: Vec<Y> = ctx
461 .each_chunk(t)
462 .iter()
463 .filter_map(|c| eval(inner, c))
464 .collect();
465 return (!l.is_empty() || keep(node)).then_some(Y::List(l));
466 }
467 if let Some(t) = o.get("singleton").and_then(Value::as_str) {
468 let inner = o.get("node")?;
469 let first = ctx.each_chunk(t).into_iter().next()?;
470 return eval(inner, &first);
471 }
472 if let Some(g) = o.get("group") {
473 let of = g.get("of").and_then(Value::as_str)?;
474 let by = g.get("by").and_then(Value::as_str)?;
475 let default = g.get("default").and_then(Value::as_str).unwrap_or("");
476 let item = g.get("item")?;
477 let mut groups: Vec<(String, Vec<Y>)> = Vec::new();
478 for c in ctx.each_chunk(of) {
479 let name = match c.field_text(by) {
480 n if n.is_empty() => default.to_string(),
481 n => n,
482 };
483 let Some(y) = eval(item, &c) else {
484 continue;
485 };
486 match groups.iter_mut().find(|(n, _)| *n == name) {
487 Some((_, items)) => items.push(y),
488 None => groups.push((name, vec![y])),
489 }
490 }
491 let l: Vec<Y> = groups
492 .into_iter()
493 .map(|(n, items)| Y::Map(vec![(n, Y::List(items))]))
494 .collect();
495 return (!l.is_empty() || keep(node)).then_some(Y::List(l));
496 }
497 if let Some(e) = o.get("entry") {
498 let key = match eval(e.get("key")?, ctx)? {
499 Y::Str(s) => s,
500 _ => return None,
501 };
502 let value = e
503 .get("value")
504 .and_then(|v| eval(v, ctx))
505 .unwrap_or(Y::Map(Vec::new()));
506 return Some(Y::Map(vec![(key, value)]));
507 }
508 if let Some(fields) = o.get("when").and_then(Value::as_array) {
509 let any = fields
510 .iter()
511 .filter_map(Value::as_str)
512 .any(|f| !ctx.field_text(f).is_empty());
513 return if any { eval(o.get("node")?, ctx) } else { None };
514 }
515 None
516 }
517 _ => None,
518 }
519}
520
521pub fn render(adapter: &Adapter, project: &Value, resolve: Resolve) -> String {
523 let chunks: Vec<&Value> = project
524 .get("chunks")
525 .and_then(Value::as_array)
526 .map(|cs| {
527 cs.iter()
528 .filter(|c| !c.get("disabled").and_then(Value::as_bool).unwrap_or(false))
529 .collect()
530 })
531 .unwrap_or_default();
532 let ctx = Ctx {
533 adapter,
534 chunks: &chunks,
535 resolve,
536 cur: None,
537 };
538 match eval(&adapter.output, &ctx) {
539 Some(root) => to_yaml(&root),
540 None => "# Generated by UnENVerse\n".to_string(),
541 }
542}
543
544pub fn starter_chunks(adapter: &Adapter, new_id: &dyn Fn() -> String) -> Vec<Value> {
546 adapter
547 .starter
548 .iter()
549 .filter_map(|s| {
550 let spec = adapter.chunk_spec(&s.type_id)?;
551 Some(new_chunk(spec, &s.name, new_id()))
552 })
553 .collect()
554}
555
556pub fn new_chunk(spec: &ChunkSpec, name: &str, id: String) -> Value {
558 serde_json::json!({
559 "id": id,
560 "name": name,
561 "chunk_type": spec.type_id,
562 "fields": spec.fields.iter().map(|f| serde_json::json!({
563 "key": f.key,
564 "value": f.default.clone().unwrap_or_default(),
565 "field_type": if f.secret { "secret" } else if f.kind == "list" { "list" } else { "var" },
566 })).collect::<Vec<_>>(),
567 })
568}
569
570fn finding(rule: &Rule, chunk: &Value, field: &str, related: Vec<String>) -> Finding {
573 let name = chunk.get("name").and_then(Value::as_str).unwrap_or("");
574 Finding {
575 rule: rule.id,
576 severity: rule.severity,
577 chunk_id: chunk
578 .get("id")
579 .and_then(Value::as_str)
580 .unwrap_or("")
581 .to_string(),
582 chunk_name: name.to_string(),
583 chunk_type: chunk_type(chunk).to_string(),
584 field: field.to_string(),
585 message: rule
588 .message
589 .replace("{name}", name)
590 .replace("{field}", field),
591 related,
592 }
593}
594
595fn set(chunk: &Value, key: &str) -> bool {
596 !raw_field(chunk, key).trim().is_empty()
597}
598
599pub fn check(adapter: &Adapter, project: &Value, vault_names: &[String]) -> Vec<Finding> {
602 let chunks: Vec<&Value> = project
603 .get("chunks")
604 .and_then(Value::as_array)
605 .map(|cs| {
606 cs.iter()
607 .filter(|c| !c.get("disabled").and_then(Value::as_bool).unwrap_or(false))
608 .collect()
609 })
610 .unwrap_or_default();
611 let mut out = Vec::new();
612 for rule in &adapter.rules {
613 let of: Vec<&Value> = chunks
614 .iter()
615 .copied()
616 .filter(|c| chunk_type(c) == rule.type_id)
617 .collect();
618 match rule.kind.as_str() {
619 "unique" => {
620 let field = rule.field.as_deref().unwrap_or("@name");
621 let mut seen: Vec<&str> = Vec::new();
622 for c in &of {
623 let v = raw_field(c, field).trim();
624 if v.is_empty() {
625 continue;
626 }
627 if seen.contains(&v) {
628 out.push(finding(rule, c, field, vec![]));
629 } else {
630 seen.push(v);
631 }
632 }
633 }
634 "required" => {
635 let field = rule.field.as_deref().unwrap_or("");
636 for c in &of {
637 if !set(c, field) {
638 out.push(finding(rule, c, field, vec![]));
639 }
640 }
641 }
642 "exclusive" => {
643 for c in &of {
644 if rule.fields.iter().all(|f| set(c, f)) {
645 out.push(finding(rule, c, &rule.fields.join(" and "), vec![]));
646 }
647 }
648 }
649 "together" => {
650 for c in &of {
651 let n = rule.fields.iter().filter(|f| set(c, f)).count();
652 if n > 0 && n < rule.fields.len() {
653 out.push(finding(rule, c, &rule.fields.join(" and "), vec![]));
654 }
655 }
656 }
657 "choices" => {
658 let field = rule.field.as_deref().unwrap_or("");
659 for c in &of {
660 let v = raw_field(c, field).trim();
661 if !v.is_empty() && !v.starts_with("${") && !rule.values.iter().any(|x| x == v)
663 {
664 out.push(finding(rule, c, field, vec![]));
665 }
666 }
667 }
668 "needs_one_of" => {
669 let when = rule.when.as_deref().unwrap_or("");
670 for c in &of {
671 if set(c, when) && !rule.fields.iter().any(|f| set(c, f)) {
672 out.push(finding(rule, c, &rule.fields.join(" or "), vec![]));
673 }
674 }
675 }
676 _ => {}
677 }
678 }
679
680 let known: Vec<String> = vault_names.iter().map(|n| norm(n)).collect();
683 let mut reported: HashSet<String> = HashSet::new();
684 for c in &chunks {
685 if adapter.chunk_spec(chunk_type(c)).is_none() {
686 continue;
687 }
688 let Some(fields) = c.get("fields").and_then(Value::as_array) else {
689 continue;
690 };
691 for f in fields {
692 let raw = f.get("value").and_then(Value::as_str).unwrap_or("").trim();
693 let Some(name) = raw.strip_prefix("${").and_then(|r| r.strip_suffix('}')) else {
694 continue;
695 };
696 if name.starts_with("chunk:") || name.starts_with("bundle:") {
697 continue; }
699 let head = norm(name.split('/').next().unwrap_or(name));
700 let resolves = known
701 .iter()
702 .any(|p| !p.is_empty() && (head == *p || head.starts_with(&format!("{p}_"))));
703 let key = f.get("key").and_then(Value::as_str).unwrap_or("");
704 if !resolves && reported.insert(format!("{}|{key}|{name}", chunk_type(c))) {
705 out.push(Finding {
706 rule: leak(format!("stack-{}-unresolved-ref", adapter.id)),
707 severity: "warning",
708 chunk_id: c
709 .get("id")
710 .and_then(Value::as_str)
711 .unwrap_or("")
712 .to_string(),
713 chunk_name: c
714 .get("name")
715 .and_then(Value::as_str)
716 .unwrap_or("")
717 .to_string(),
718 chunk_type: chunk_type(c).to_string(),
719 field: key.to_string(),
720 message: format!("`{key}` reads `${{{name}}}`, which is not a vault entry"),
721 related: vec![],
722 });
723 }
724 }
725 }
726 out
727}
728
729fn norm(s: &str) -> String {
730 s.trim()
731 .to_lowercase()
732 .chars()
733 .map(|c| if c.is_alphanumeric() { c } else { '_' })
734 .collect()
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use serde_json::json;
741
742 fn chunk(t: &str, name: &str, fields: &[(&str, &str)]) -> Value {
743 json!({
744 "id": format!("id-{name}"), "name": name, "chunk_type": t,
745 "fields": fields.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>()
746 })
747 }
748
749 fn project(ptype: &str, chunks: Vec<Value>) -> Value {
750 json!({"id": "p", "name": "p", "project_type": ptype, "chunks": chunks})
751 }
752
753 fn plain(raw: &str, _secret: bool) -> String {
754 raw.to_string()
755 }
756
757 fn render_plain(id: &str, p: &Value) -> String {
758 render(adapter(id).unwrap(), p, &plain)
759 }
760
761 #[test]
762 fn the_descriptors_are_internally_consistent() {
763 assert_eq!(
764 adapters().iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
765 vec!["prometheus", "grafana", "homepage"]
766 );
767 let mut ids = HashSet::new();
768 let mut types = HashSet::new();
769 for a in adapters() {
770 assert!(ids.insert(&a.id), "duplicate adapter {}", a.id);
771 assert!(!a.file.is_empty() && !a.label.is_empty() && !a.abbr.is_empty());
772 for c in &a.chunks {
773 assert!(
774 types.insert(&c.type_id),
775 "chunk type {} is in two adapters",
776 c.type_id
777 );
778 assert!(c.type_id.starts_with(match a.id.as_str() {
779 "prometheus" => "prom_",
780 "grafana" => "grafana_",
781 _ => "homepage_",
782 }));
783 let keys: HashSet<_> = c.fields.iter().map(|f| &f.key).collect();
784 assert_eq!(
785 keys.len(),
786 c.fields.len(),
787 "duplicate field in {}",
788 c.type_id
789 );
790 }
791 for s in &a.starter {
792 assert!(
793 a.chunk_spec(&s.type_id).is_some(),
794 "starter names unknown chunk {}",
795 s.type_id
796 );
797 }
798 for r in &a.rules {
800 let spec = a
801 .chunk_spec(&r.type_id)
802 .unwrap_or_else(|| panic!("rule {} names unknown chunk {}", r.id, r.type_id));
803 let mut named: Vec<&str> = r.fields.iter().map(String::as_str).collect();
804 named.extend(r.field.as_deref());
805 named.extend(r.when.as_deref());
806 for f in named {
807 assert!(
808 f == "@name" || spec.fields.iter().any(|x| x.key == f),
809 "rule {} names unknown field {f}",
810 r.id
811 );
812 }
813 assert!(
814 [
815 "unique",
816 "required",
817 "exclusive",
818 "together",
819 "choices",
820 "needs_one_of"
821 ]
822 .contains(&r.kind.as_str()),
823 "rule kind {}",
824 r.kind
825 );
826 }
827 }
828 }
829
830 #[test]
831 fn every_field_the_output_reads_exists_in_the_chunk_it_reads_from() {
832 fn walk(n: &Value, chunk: Option<&str>, a: &Adapter, bad: &mut Vec<String>) {
834 match n {
835 Value::Object(o) => {
836 if let Some(f) = o.get("f").and_then(Value::as_str) {
837 let ok = chunk
838 .and_then(|c| a.chunk_spec(c))
839 .is_some_and(|s| f == "@name" || s.fields.iter().any(|x| x.key == f));
840 if !ok {
841 bad.push(format!("{}: field '{f}' not in {chunk:?}", a.id));
842 }
843 }
844 if let Some(fs) = o.get("when").and_then(Value::as_array) {
845 for f in fs.iter().filter_map(Value::as_str) {
846 let ok = chunk
847 .and_then(|c| a.chunk_spec(c))
848 .is_some_and(|s| s.fields.iter().any(|x| x.key == f));
849 if !ok {
850 bad.push(format!("{}: when '{f}' not in {chunk:?}", a.id));
851 }
852 }
853 }
854 let inner = o
855 .get("each")
856 .or_else(|| o.get("singleton"))
857 .and_then(Value::as_str)
858 .or_else(|| {
859 o.get("group")
860 .and_then(|g| g.get("of"))
861 .and_then(Value::as_str)
862 })
863 .or(chunk);
864 if let Some(g) = o.get("group") {
865 if let Some(by) = g.get("by").and_then(Value::as_str) {
866 let of = g.get("of").and_then(Value::as_str);
867 let ok = of
868 .and_then(|c| a.chunk_spec(c))
869 .is_some_and(|s| s.fields.iter().any(|x| x.key == by));
870 if !ok {
871 bad.push(format!("{}: group by '{by}' not in {of:?}", a.id));
872 }
873 }
874 }
875 for (k, v) in o {
876 if k == "f" || k == "when" {
877 continue;
878 }
879 walk(v, inner, a, bad);
880 }
881 }
882 Value::Array(arr) => arr.iter().for_each(|v| walk(v, chunk, a, bad)),
883 _ => {}
884 }
885 }
886 for a in adapters() {
887 let mut bad = Vec::new();
888 walk(&a.output, None, a, &mut bad);
889 assert!(bad.is_empty(), "{bad:?}");
890 }
891 }
892
893 #[test]
894 fn a_prometheus_project_renders_a_prometheus_file() {
895 let p = project(
896 "prometheus",
897 vec![
898 chunk(
899 "prom_global",
900 "global",
901 &[("scrape_interval", "30s"), ("evaluation_interval", "15s")],
902 ),
903 chunk(
904 "prom_scrape",
905 "node",
906 &[("targets", "a:9100\nb:9100"), ("metrics_path", "/metrics")],
907 ),
908 chunk(
909 "prom_scrape",
910 "app",
911 &[
912 ("targets", "app:8080"),
913 ("scheme", "https"),
914 ("username", "scraper"),
915 ("password", "s3cret"),
916 ("ca_file", "/etc/ca.pem"),
917 ],
918 ),
919 chunk(
920 "prom_scrape",
921 "api",
922 &[("targets", "api:8080"), ("bearer_token", "tok")],
923 ),
924 chunk(
925 "prom_remote_write",
926 "cloud",
927 &[
928 ("url", "https://prom.example/api/v1/write"),
929 ("username", "u"),
930 ("password", "p"),
931 ],
932 ),
933 ],
934 );
935 assert_eq!(
936 render_plain("prometheus", &p),
937 "# Generated by UnENVerse
938global:
939 scrape_interval: \"30s\"
940 evaluation_interval: \"15s\"
941scrape_configs:
942 - job_name: node
943 metrics_path: \"/metrics\"
944 static_configs:
945 - targets:
946 - \"a:9100\"
947 - \"b:9100\"
948 - job_name: app
949 scheme: https
950 basic_auth:
951 username: scraper
952 password: s3cret
953 tls_config:
954 ca_file: \"/etc/ca.pem\"
955 static_configs:
956 - targets:
957 - \"app:8080\"
958 - job_name: api
959 authorization:
960 type: Bearer
961 credentials: tok
962 static_configs:
963 - targets:
964 - \"api:8080\"
965remote_write:
966 - url: \"https://prom.example/api/v1/write\"
967 basic_auth:
968 username: u
969 password: p
970"
971 );
972 }
973
974 #[test]
975 fn an_empty_scrape_list_is_written_not_dropped_and_empty_sections_vanish() {
976 let p = project("prometheus", vec![]);
977 assert_eq!(
978 render_plain("prometheus", &p),
979 "# Generated by UnENVerse\nscrape_configs: []\n"
980 );
981 let only_global = project(
982 "prometheus",
983 vec![chunk("prom_global", "g", &[("scrape_interval", "1m")])],
984 );
985 assert!(render_plain("prometheus", &only_global)
986 .starts_with("# Generated by UnENVerse\nglobal:\n scrape_interval: \"1m\"\n"));
987 }
988
989 #[test]
990 fn a_disabled_chunk_is_left_out() {
991 let mut off = chunk("prom_scrape", "paused", &[("targets", "x:1")]);
992 off["disabled"] = json!(true);
993 let p = project(
994 "prometheus",
995 vec![off, chunk("prom_scrape", "active", &[("targets", "y:2")])],
996 );
997 let out = render_plain("prometheus", &p);
998 assert!(out.contains("job_name: active") && !out.contains("paused"));
999 }
1000
1001 #[test]
1002 fn strings_that_yaml_would_read_as_something_else_are_quoted() {
1003 let p = project(
1004 "prometheus",
1005 vec![chunk(
1006 "prom_scrape",
1007 "j",
1008 &[
1009 ("targets", "h:1"),
1010 ("username", "true"),
1011 ("password", "123456"),
1012 ],
1013 )],
1014 );
1015 let out = render_plain("prometheus", &p);
1016 assert!(out.contains("username: \"true\""), "{out}");
1017 assert!(out.contains("password: \"123456\""), "{out}");
1018 for (s, want) in [
1019 ("plain", "plain"),
1020 ("with-dash_and.dot", "with-dash_and.dot"),
1021 ("", "\"\""),
1022 ("null", "\"null\""),
1023 ("No", "\"No\""),
1024 ("~", "\"~\""),
1025 ("0x1F", "\"0x1F\""),
1026 ("1e3", "\"1e3\""),
1027 ("a: b", "\"a: b\""),
1028 ("- x", "\"- x\""),
1029 ("# c", "\"# c\""),
1030 ("line\nbreak", "\"line\\nbreak\""),
1031 ("quo\"te", "\"quo\\\"te\""),
1032 ("ünï", "\"ünï\""),
1033 ] {
1034 assert_eq!(scalar(s), want, "{s:?}");
1035 }
1036 }
1037
1038 #[test]
1039 fn the_resolver_decides_what_a_reference_becomes_and_is_told_which_fields_are_secret() {
1040 let p = project(
1041 "prometheus",
1042 vec![chunk(
1043 "prom_scrape",
1044 "j",
1045 &[
1046 ("targets", "h:1"),
1047 ("username", "u"),
1048 ("password", "${Prom/password}"),
1049 ],
1050 )],
1051 );
1052 let seen = std::cell::RefCell::new(Vec::new());
1053 let r = |raw: &str, secret: bool| {
1054 seen.borrow_mut().push((raw.to_string(), secret));
1055 if raw.starts_with("${") {
1056 "RESOLVED".into()
1057 } else {
1058 raw.to_string()
1059 }
1060 };
1061 let out = render(adapter("prometheus").unwrap(), &p, &r);
1062 assert!(out.contains("password: RESOLVED"), "{out}");
1063 let seen = seen.borrow();
1064 assert!(seen.contains(&("${Prom/password}".to_string(), true)));
1065 assert!(
1066 seen.contains(&("u".to_string(), false)),
1067 "username is not secret"
1068 );
1069 }
1070
1071 #[test]
1072 fn grafana_datasources_carry_credentials_in_secure_json_data() {
1073 let p = project(
1074 "grafana",
1075 vec![
1076 chunk(
1077 "grafana_datasource",
1078 "Prometheus",
1079 &[
1080 ("type", "prometheus"),
1081 ("url", "http://prometheus:9090"),
1082 ("access", "proxy"),
1083 ("is_default", "true"),
1084 ("api_token", "tok"),
1085 ],
1086 ),
1087 chunk(
1088 "grafana_datasource",
1089 "pg",
1090 &[
1091 ("type", "postgres"),
1092 ("url", "db:5432"),
1093 ("user", "grafana"),
1094 ("password", "pw"),
1095 ("database", "app"),
1096 ],
1097 ),
1098 ],
1099 );
1100 assert_eq!(
1101 render_plain("grafana", &p),
1102 "# Generated by UnENVerse
1103apiVersion: 1
1104datasources:
1105 - name: Prometheus
1106 type: prometheus
1107 access: proxy
1108 url: \"http://prometheus:9090\"
1109 isDefault: true
1110 jsonData:
1111 httpHeaderName1: Authorization
1112 secureJsonData:
1113 httpHeaderValue1: \"Bearer tok\"
1114 - name: pg
1115 type: postgres
1116 url: \"db:5432\"
1117 user: grafana
1118 database: app
1119 secureJsonData:
1120 password: pw
1121"
1122 );
1123 }
1124
1125 #[test]
1126 fn homepage_groups_services_in_first_seen_order_and_attaches_widget_credentials() {
1127 let p = project(
1128 "homepage",
1129 vec![
1130 chunk(
1131 "homepage_service",
1132 "Jellyfin",
1133 &[
1134 ("group", "Media"),
1135 ("href", "http://jf:8096"),
1136 ("icon", "jellyfin.png"),
1137 ("widget_type", "jellyfin"),
1138 ("widget_url", "http://jf:8096"),
1139 ("widget_key", "k1"),
1140 ],
1141 ),
1142 chunk(
1143 "homepage_service",
1144 "Pi-hole",
1145 &[("group", "Network"), ("href", "http://pi.hole/admin")],
1146 ),
1147 chunk(
1148 "homepage_service",
1149 "Sonarr",
1150 &[
1151 ("group", "Media"),
1152 ("widget_type", "sonarr"),
1153 ("widget_url", "http://sonarr:8989"),
1154 ("widget_key", "k2"),
1155 ],
1156 ),
1157 chunk("homepage_service", "Loose", &[("group", "")]),
1158 ],
1159 );
1160 assert_eq!(
1161 render_plain("homepage", &p),
1162 "# Generated by UnENVerse
1163- Media:
1164 - Jellyfin:
1165 icon: jellyfin.png
1166 href: \"http://jf:8096\"
1167 widget:
1168 type: jellyfin
1169 url: \"http://jf:8096\"
1170 key: k1
1171 - Sonarr:
1172 widget:
1173 type: sonarr
1174 url: \"http://sonarr:8989\"
1175 key: k2
1176- Network:
1177 - Pi-hole:
1178 href: \"http://pi.hole/admin\"
1179- Services:
1180 - Loose: {}
1181"
1182 );
1183 }
1184
1185 #[test]
1186 fn starter_chunks_carry_the_descriptors_defaults() {
1187 let a = adapter("prometheus").unwrap();
1188 let n = std::cell::Cell::new(0);
1189 let chunks = starter_chunks(a, &|| {
1190 n.set(n.get() + 1);
1191 format!("id{}", n.get())
1192 });
1193 assert_eq!(chunks.len(), 2);
1194 assert_eq!(chunks[0]["chunk_type"], "prom_global");
1195 let targets = chunks[1]["fields"]
1196 .as_array()
1197 .unwrap()
1198 .iter()
1199 .find(|f| f["key"] == "targets")
1200 .unwrap();
1201 assert_eq!(targets["value"], "localhost:9090");
1202 assert_eq!(targets["field_type"], "list");
1203 let pw = chunks[1]["fields"]
1204 .as_array()
1205 .unwrap()
1206 .iter()
1207 .find(|f| f["key"] == "password")
1208 .unwrap();
1209 assert_eq!(pw["field_type"], "secret");
1210 }
1211
1212 fn rules_of(a: &str, p: &Value, names: &[&str]) -> Vec<(String, String)> {
1213 let names: Vec<String> = names.iter().map(|s| s.to_string()).collect();
1214 check(adapter(a).unwrap(), p, &names)
1215 .into_iter()
1216 .map(|f| (f.rule.to_string(), f.chunk_name))
1217 .collect()
1218 }
1219
1220 #[test]
1221 fn each_prometheus_rule_fires_on_its_case_and_stays_silent_on_a_clean_file() {
1222 let clean = project(
1223 "prometheus",
1224 vec![chunk(
1225 "prom_scrape",
1226 "a",
1227 &[
1228 ("targets", "x:1"),
1229 ("scheme", "https"),
1230 ("username", "u"),
1231 ("password", "p"),
1232 ],
1233 )],
1234 );
1235 assert!(rules_of("prometheus", &clean, &[]).is_empty());
1236 let dup = project(
1237 "prometheus",
1238 vec![
1239 chunk("prom_scrape", "a", &[("targets", "x:1")]),
1240 chunk("prom_scrape", "a", &[("targets", "y:1")]),
1241 ],
1242 );
1243 assert_eq!(
1244 rules_of("prometheus", &dup, &[]),
1245 vec![("stack-prometheus-duplicate-job".into(), "a".into())]
1246 );
1247 let none = project(
1248 "prometheus",
1249 vec![chunk("prom_scrape", "a", &[("targets", " ")])],
1250 );
1251 assert_eq!(
1252 rules_of("prometheus", &none, &[])[0].0,
1253 "stack-prometheus-job-without-targets"
1254 );
1255 let both = project(
1256 "prometheus",
1257 vec![chunk(
1258 "prom_scrape",
1259 "a",
1260 &[
1261 ("targets", "x:1"),
1262 ("username", "u"),
1263 ("password", "p"),
1264 ("bearer_token", "t"),
1265 ],
1266 )],
1267 );
1268 assert_eq!(
1269 rules_of("prometheus", &both, &[])[0].0,
1270 "stack-prometheus-auth-conflict"
1271 );
1272 let half = project(
1273 "prometheus",
1274 vec![chunk(
1275 "prom_scrape",
1276 "a",
1277 &[("targets", "x:1"), ("username", "u")],
1278 )],
1279 );
1280 assert_eq!(
1281 rules_of("prometheus", &half, &[])[0].0,
1282 "stack-prometheus-basic-auth-incomplete"
1283 );
1284 let scheme = project(
1285 "prometheus",
1286 vec![chunk(
1287 "prom_scrape",
1288 "a",
1289 &[("targets", "x:1"), ("scheme", "ftp")],
1290 )],
1291 );
1292 assert_eq!(
1293 rules_of("prometheus", &scheme, &[])[0].0,
1294 "stack-prometheus-bad-scheme"
1295 );
1296 let rw = project(
1297 "prometheus",
1298 vec![chunk("prom_remote_write", "r", &[("url", "")])],
1299 );
1300 assert_eq!(
1301 rules_of("prometheus", &rw, &[])[0].0,
1302 "stack-prometheus-remote-write-without-url"
1303 );
1304 }
1305
1306 #[test]
1307 fn homepage_and_grafana_rules() {
1308 let dup = project(
1309 "homepage",
1310 vec![
1311 chunk("homepage_service", "A", &[]),
1312 chunk("homepage_service", "A", &[]),
1313 ],
1314 );
1315 assert_eq!(
1316 rules_of("homepage", &dup, &[])[0].0,
1317 "stack-homepage-duplicate-service"
1318 );
1319 let w = project(
1320 "homepage",
1321 vec![chunk(
1322 "homepage_service",
1323 "A",
1324 &[("widget_type", "sonarr"), ("widget_url", "http://x")],
1325 )],
1326 );
1327 assert_eq!(
1328 rules_of("homepage", &w, &[])[0].0,
1329 "stack-homepage-widget-without-credential"
1330 );
1331 let ok = project(
1332 "homepage",
1333 vec![chunk(
1334 "homepage_service",
1335 "A",
1336 &[
1337 ("widget_type", "sonarr"),
1338 ("widget_url", "http://x"),
1339 ("widget_key", "k"),
1340 ],
1341 )],
1342 );
1343 assert!(rules_of("homepage", &ok, &[]).is_empty());
1344 let half = project(
1345 "homepage",
1346 vec![chunk(
1347 "homepage_service",
1348 "A",
1349 &[("widget_type", "sonarr"), ("widget_key", "k")],
1350 )],
1351 );
1352 assert_eq!(
1353 rules_of("homepage", &half, &[])[0].0,
1354 "stack-homepage-widget-without-url"
1355 );
1356 let g = project(
1357 "grafana",
1358 vec![chunk(
1359 "grafana_datasource",
1360 "d",
1361 &[("type", ""), ("access", "weird")],
1362 )],
1363 );
1364 let got: Vec<String> = rules_of("grafana", &g, &[])
1365 .into_iter()
1366 .map(|r| r.0)
1367 .collect();
1368 assert!(got.contains(&"stack-grafana-datasource-without-type".to_string()));
1369 assert!(got.contains(&"stack-grafana-bad-access".to_string()));
1370 }
1371
1372 #[test]
1373 fn a_reference_to_nothing_in_the_vault_is_flagged_once_and_a_real_one_is_not() {
1374 let p = project(
1375 "prometheus",
1376 vec![chunk(
1377 "prom_scrape",
1378 "a",
1379 &[
1380 ("targets", "x:1"),
1381 ("username", "u"),
1382 ("password", "${Ghost/password}"),
1383 ],
1384 )],
1385 );
1386 let found = rules_of("prometheus", &p, &["Prometheus"]);
1387 assert_eq!(
1388 found,
1389 vec![("stack-prometheus-unresolved-ref".into(), "a".into())]
1390 );
1391 assert!(rules_of("prometheus", &p, &["Ghost"]).is_empty());
1392 let keyed = project(
1393 "prometheus",
1394 vec![chunk(
1395 "prom_scrape",
1396 "a",
1397 &[
1398 ("targets", "x:1"),
1399 ("username", "u"),
1400 ("password", "${Ghost_Admin}"),
1401 ],
1402 )],
1403 );
1404 assert!(
1405 rules_of("prometheus", &keyed, &["Ghost"]).is_empty(),
1406 "PROVIDER_KEYID still names the provider"
1407 );
1408 let chunkref = project(
1409 "prometheus",
1410 vec![chunk(
1411 "prom_scrape",
1412 "a",
1413 &[
1414 ("targets", "x:1"),
1415 ("username", "u"),
1416 ("password", "${chunk:other/pw}"),
1417 ],
1418 )],
1419 );
1420 assert!(rules_of("prometheus", &chunkref, &[]).is_empty());
1421 }
1422
1423 #[test]
1424 fn a_finding_never_carries_a_field_value() {
1425 let p = project(
1426 "prometheus",
1427 vec![chunk(
1428 "prom_scrape",
1429 "a",
1430 &[
1431 ("targets", "x:1"),
1432 ("username", "alice"),
1433 ("password", "hunter2-SECRET"),
1434 ("bearer_token", "tok-SECRET"),
1435 ],
1436 )],
1437 );
1438 for f in check(adapter("prometheus").unwrap(), &p, &[]) {
1439 let all = format!("{} {} {}", f.message, f.field, f.related.join(" "));
1440 assert!(!all.contains("SECRET") && !all.contains("alice"), "{all}");
1441 }
1442 }
1443}