1use alembic_core::{key_string, uid_v5, Inventory, JsonMap, Key, Object, Schema, TypeName, Uid};
4use anyhow::{anyhow, Context, Result};
5use serde::Deserialize;
6use serde_json::{Map as JsonObject, Value as JsonValue};
7use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
8use std::collections::BTreeMap;
9use std::path::Path;
10use uuid::Uuid;
11
12#[derive(Debug, Deserialize)]
13pub struct Retort {
14 pub version: u32,
15 pub schema: Schema,
16 #[serde(default)]
17 pub rules: Vec<Rule>,
18}
19
20#[derive(Debug, Deserialize)]
21pub struct Rule {
22 pub name: String,
23 pub select: String,
24 #[serde(default)]
26 pub vars: BTreeMap<String, VarSpec>,
27 #[serde(default)]
29 pub uids: BTreeMap<String, EmitUid>,
30 pub emit: EmitSpec,
32}
33
34#[derive(Debug, Deserialize)]
36#[serde(untagged)]
37pub enum EmitSpec {
38 Single(Emit),
39 Multi(Vec<Emit>),
40}
41
42#[derive(Debug, Deserialize)]
43pub struct Emit {
44 #[serde(rename = "type", alias = "kind")]
45 pub type_name: String,
46 pub key: BTreeMap<String, YamlValue>,
47 #[serde(default)]
48 pub uid: Option<EmitUid>,
49 #[serde(default)]
50 pub vars: BTreeMap<String, VarSpec>,
51 #[serde(default)]
52 pub attrs: BTreeMap<String, YamlValue>,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct VarSpec {
57 pub from: String,
58 #[serde(default)]
59 pub required: bool,
60}
61
62#[derive(Debug, Deserialize)]
63#[serde(untagged)]
64pub enum EmitUid {
65 V5 { v5: UidV5Spec },
66 Template(String),
67}
68
69#[derive(Debug, Deserialize)]
70pub struct UidV5Spec {
71 #[serde(rename = "type", alias = "kind")]
72 pub type_name: String,
73 pub stable: String,
74}
75
76#[derive(Debug, Clone)]
77enum SelectorToken {
78 Key(String),
79 Index(usize),
80 Wildcard,
81}
82
83#[derive(Debug, Clone)]
84enum PathToken {
85 Key(String),
86 Index(usize),
87}
88
89#[derive(Debug)]
90struct RelativePath {
91 up: usize,
92 selectors: Vec<SelectorToken>,
93}
94
95pub fn load_retort(path: impl AsRef<Path>) -> Result<Retort> {
96 let path = path.as_ref();
97 let raw = std::fs::read_to_string(path)
98 .with_context(|| format!("read retort: {}", path.display()))?;
99 let retort: Retort =
100 serde_yaml::from_str(&raw).with_context(|| format!("parse retort: {}", path.display()))?;
101 Ok(retort)
102}
103
104pub fn load_raw_yaml(path: impl AsRef<Path>) -> Result<YamlValue> {
105 let path = path.as_ref();
106 let raw = std::fs::read_to_string(path)
107 .with_context(|| format!("read raw yaml: {}", path.display()))?;
108 let value: YamlValue =
109 serde_yaml::from_str(&raw).with_context(|| format!("parse yaml: {}", path.display()))?;
110 Ok(value)
111}
112
113pub fn is_brew_format(raw: &YamlValue) -> bool {
114 let YamlValue::Mapping(map) = raw else {
115 return false;
116 };
117 map.contains_key(YamlValue::String("objects".to_string()))
118}
119
120pub fn compile_retort(raw: &YamlValue, retort: &Retort) -> Result<Inventory> {
121 if retort.version != 1 {
122 return Err(anyhow!("retort version {} is unsupported", retort.version));
123 }
124
125 let mut objects = Vec::new();
126
127 for rule in &retort.rules {
128 let selectors = parse_selector_path(&rule.select)
129 .with_context(|| format!("rule {}: invalid select path", rule.name))?;
130 let mut selected = Vec::new();
131 select_paths(raw, &selectors, &mut Vec::new(), &mut selected);
132
133 let emits = match &rule.emit {
134 EmitSpec::Single(emit) => vec![emit],
135 EmitSpec::Multi(emits) => emits.iter().collect(),
136 };
137
138 for path in selected {
139 let mut vars = extract_vars(raw, &path, &rule.vars, &rule.name)?;
141
142 for (uid_name, uid_spec) in &rule.uids {
144 let uid = resolve_named_uid(uid_spec, &vars, &rule.name, uid_name)?;
145 vars.insert(
146 format!("uids.{}", uid_name),
147 JsonValue::String(uid.to_string()),
148 );
149 }
150
151 for emit in &emits {
153 let mut emit_vars = vars.clone();
155 let emit_specific_vars = extract_vars(raw, &path, &emit.vars, &rule.name)?;
156 emit_vars.extend(emit_specific_vars);
157
158 let key = render_key(&emit.key, &emit_vars, &rule.name)?;
159 let uid = resolve_emit_uid(emit, &emit_vars, &rule.name, &key)?;
160 let type_name = TypeName::new(render_template(
161 &emit.type_name,
162 &emit_vars,
163 &rule.name,
164 "type",
165 )?);
166 let attrs = render_attrs(&emit.attrs, &emit_vars, &rule.name, "attrs")?;
167 let object = build_object(uid, type_name, key, attrs)?;
168 objects.push(object);
169 }
170 }
171 }
172
173 objects.sort_by(|a, b| {
174 inventory_sort_key(&a.type_name, &a.key).cmp(&inventory_sort_key(&b.type_name, &b.key))
175 });
176
177 let inventory = Inventory {
178 schema: retort.schema.clone(),
179 objects,
180 };
181 crate::report_to_result(crate::validate(&inventory))?;
182 Ok(inventory)
183}
184
185fn build_object(
186 uid: Uid,
187 type_name: TypeName,
188 key: Key,
189 attrs: JsonObject<String, JsonValue>,
190) -> Result<Object> {
191 let attrs_value = JsonValue::Object(attrs);
192 let attrs = to_object_map(attrs_value)?;
193 Ok(Object::new(uid, type_name, key, attrs)?)
194}
195
196fn to_object_map(value: JsonValue) -> Result<JsonMap> {
197 match value {
198 JsonValue::Object(map) => Ok(map.into_iter().collect::<BTreeMap<_, _>>().into()),
199 _ => Err(anyhow!("attrs must be an object")),
200 }
201}
202
203fn extract_vars(
204 raw: &YamlValue,
205 path: &[PathToken],
206 specs: &BTreeMap<String, VarSpec>,
207 rule: &str,
208) -> Result<BTreeMap<String, JsonValue>> {
209 let mut vars = BTreeMap::new();
210 for (name, spec) in specs {
211 let rel = parse_relative_path(&spec.from)
212 .with_context(|| format!("rule {rule}: invalid var path for {name}: {}", spec.from))?;
213 let values = extract_values(raw, path, &rel)?;
214 if values.is_empty() {
215 if spec.required {
216 return Err(anyhow!(
217 "rule {rule}: missing required var {name} from {}",
218 spec.from
219 ));
220 }
221 continue;
222 }
223 let json_value = if values.len() == 1 {
224 yaml_to_json(values[0].clone())?
225 } else {
226 let mut items = Vec::new();
227 for value in values {
228 items.push(yaml_to_json(value.clone())?);
229 }
230 JsonValue::Array(items)
231 };
232 vars.insert(name.clone(), json_value);
233 }
234 Ok(vars)
235}
236
237fn resolve_emit_uid(
238 emit: &Emit,
239 vars: &BTreeMap<String, JsonValue>,
240 rule: &str,
241 key: &Key,
242) -> Result<Uid> {
243 match emit.uid.as_ref() {
244 Some(EmitUid::V5 { v5 }) => resolve_uid_v5(v5, vars, rule, "uid"),
245 Some(EmitUid::Template(template)) => resolve_uid_template(template, vars, rule),
246 None => Ok(uid_v5(&emit.type_name, &key_string(key))),
247 }
248}
249
250fn resolve_named_uid(
251 uid_spec: &EmitUid,
252 vars: &BTreeMap<String, JsonValue>,
253 rule: &str,
254 uid_name: &str,
255) -> Result<Uid> {
256 let context = format!("uids.{}", uid_name);
257 match uid_spec {
258 EmitUid::V5 { v5 } => resolve_uid_v5(v5, vars, rule, &context),
259 EmitUid::Template(template) => resolve_uid_template(template, vars, rule),
260 }
261}
262
263fn resolve_uid_v5(
264 spec: &UidV5Spec,
265 vars: &BTreeMap<String, JsonValue>,
266 rule: &str,
267 context: &str,
268) -> Result<Uid> {
269 let kind = render_template(&spec.type_name, vars, rule, context)?;
270 let stable = render_template(&spec.stable, vars, rule, context)?;
271 if kind.trim().is_empty() || stable.trim().is_empty() {
272 return Err(anyhow!(
273 "rule {rule}: uid v5 requires non-empty type and stable values"
274 ));
275 }
276 Ok(uid_v5(&kind, &stable))
277}
278
279fn resolve_uid_template(
280 template: &str,
281 vars: &BTreeMap<String, JsonValue>,
282 rule: &str,
283) -> Result<Uid> {
284 let rendered = render_template(template, vars, rule, "uid")?;
285 let parsed = Uuid::parse_str(&rendered)
286 .with_context(|| format!("rule {rule}: uid template is not a valid uuid: {rendered}"))?;
287 Ok(parsed)
288}
289
290fn render_attrs(
291 attrs: &BTreeMap<String, YamlValue>,
292 vars: &BTreeMap<String, JsonValue>,
293 rule: &str,
294 context: &str,
295) -> Result<JsonObject<String, JsonValue>> {
296 let mut map = JsonObject::new();
297 for (key, value) in attrs {
298 let rendered = render_yaml_value(value, vars, rule, context, false)?;
299 if let Some(value) = rendered {
300 map.insert(key.clone(), value);
301 }
302 }
303 Ok(map)
304}
305
306fn render_key(
307 key: &BTreeMap<String, YamlValue>,
308 vars: &BTreeMap<String, JsonValue>,
309 rule: &str,
310) -> Result<Key> {
311 let mut map = BTreeMap::new();
312 for (field, value) in key {
313 let context = format!("key.{field}");
314 let rendered = render_yaml_value(value, vars, rule, &context, false)?;
315 let Some(value) = rendered else {
316 return Err(anyhow!("rule {rule}: missing value for {context}"));
317 };
318 map.insert(field.clone(), value);
319 }
320 Ok(Key::from(map))
321}
322
323fn render_yaml_value(
324 value: &YamlValue,
325 vars: &BTreeMap<String, JsonValue>,
326 rule: &str,
327 context: &str,
328 allow_missing: bool,
329) -> Result<Option<JsonValue>> {
330 match value {
331 YamlValue::String(raw) => render_string_value(raw, vars, rule, context, allow_missing),
332 YamlValue::Sequence(items) => {
333 let mut rendered = Vec::new();
334 for item in items {
335 let value = render_yaml_value(item, vars, rule, context, allow_missing)?;
336 match value {
337 Some(value) => rendered.push(value),
338 None => {
339 if allow_missing {
340 return Ok(None);
341 }
342 return Err(anyhow!("rule {rule}: missing value in {context}"));
343 }
344 }
345 }
346 Ok(Some(JsonValue::Array(rendered)))
347 }
348 YamlValue::Mapping(map) => {
349 if let Some((optional, spec)) = parse_uid_mapping(map) {
350 return render_uid_mapping(&spec, vars, rule, context, optional);
351 }
352
353 let mut rendered = JsonObject::new();
354 for (key, value) in map {
355 let key = key
356 .as_str()
357 .ok_or_else(|| anyhow!("rule {rule}: {context} keys must be strings"))?
358 .to_string();
359 let value = render_yaml_value(value, vars, rule, context, allow_missing)?;
360 match value {
361 Some(value) => {
362 rendered.insert(key, value);
363 }
364 None => {
365 if allow_missing {
366 return Ok(None);
367 }
368 return Err(anyhow!("rule {rule}: missing value in {context}"));
369 }
370 }
371 }
372 Ok(Some(JsonValue::Object(rendered)))
373 }
374 _ => Ok(Some(yaml_to_json(value.clone())?)),
375 }
376}
377
378fn render_uid_mapping(
379 spec: &UidV5Spec,
380 vars: &BTreeMap<String, JsonValue>,
381 rule: &str,
382 context: &str,
383 optional: bool,
384) -> Result<Option<JsonValue>> {
385 let kind = render_template_optional(&spec.type_name, vars, rule, context, optional)?;
386 let stable = render_template_optional(&spec.stable, vars, rule, context, optional)?;
387 let (Some(kind), Some(stable)) = (kind, stable) else {
388 return Ok(None);
389 };
390 if kind.trim().is_empty() || stable.trim().is_empty() {
391 if optional {
392 return Ok(None);
393 }
394 return Err(anyhow!(
395 "rule {rule}: uid mapping requires non-empty type and stable"
396 ));
397 }
398 let uid = uid_v5(&kind, &stable);
399 Ok(Some(JsonValue::String(uid.to_string())))
400}
401
402fn render_string_value(
403 raw: &str,
404 vars: &BTreeMap<String, JsonValue>,
405 rule: &str,
406 context: &str,
407 allow_missing: bool,
408) -> Result<Option<JsonValue>> {
409 if let Some(var) = placeholder_only(raw) {
410 if let Some(value) = vars.get(var) {
411 if value.is_null() && allow_missing {
412 return Ok(None);
413 }
414 return Ok(Some(value.clone()));
415 }
416 if allow_missing {
417 return Ok(None);
418 }
419 return Err(anyhow!("rule {rule}: missing var {var} in {context}"));
420 }
421
422 if raw.contains("${") {
423 let rendered = render_template_optional(raw, vars, rule, context, allow_missing)?;
424 return Ok(rendered.map(JsonValue::String));
425 }
426
427 Ok(Some(JsonValue::String(raw.to_string())))
428}
429
430fn render_template(
431 template: &str,
432 vars: &BTreeMap<String, JsonValue>,
433 rule: &str,
434 context: &str,
435) -> Result<String> {
436 render_template_optional(template, vars, rule, context, false)?
437 .ok_or_else(|| anyhow!("rule {rule}: missing vars for template {template}"))
438}
439
440fn render_template_optional(
441 template: &str,
442 vars: &BTreeMap<String, JsonValue>,
443 rule: &str,
444 context: &str,
445 allow_missing: bool,
446) -> Result<Option<String>> {
447 let mut rendered = String::new();
448 let mut rest = template;
449
450 while let Some(start) = rest.find("${") {
451 rendered.push_str(&rest[..start]);
452 let after = &rest[start + 2..];
453 let Some(end) = after.find('}') else {
454 return Err(anyhow!(
455 "rule {rule}: unterminated template in {context}: {template}"
456 ));
457 };
458 let name = &after[..end];
459 let value = vars.get(name);
460 let Some(value) = value else {
461 if allow_missing {
462 return Ok(None);
463 }
464 return Err(anyhow!("rule {rule}: missing var {name} in {context}"));
465 };
466 if value.is_null() && allow_missing {
467 return Ok(None);
468 }
469 let Some(value) = value.as_str() else {
470 return Err(anyhow!(
471 "rule {rule}: var {name} in {context} must be a string"
472 ));
473 };
474 rendered.push_str(value);
475 rest = &after[end + 1..];
476 }
477 rendered.push_str(rest);
478 Ok(Some(rendered))
479}
480
481fn placeholder_only(input: &str) -> Option<&str> {
482 if !input.starts_with("${") || !input.ends_with('}') {
483 return None;
484 }
485 let inner = &input[2..input.len() - 1];
486 if inner.contains("${") || inner.contains('}') || inner.is_empty() {
487 return None;
488 }
489 Some(inner)
490}
491
492fn parse_uid_mapping(map: &YamlMapping) -> Option<(bool, UidV5Spec)> {
493 if map.len() != 1 {
494 return None;
495 }
496 let (key, value) = map.iter().next()?;
497 let key = key.as_str()?;
498 let optional = match key {
499 "uid" => false,
500 "uid?" => true,
501 _ => return None,
502 };
503 let YamlValue::Mapping(inner) = value else {
504 return None;
505 };
506 let kind = inner
507 .get(YamlValue::String("type".to_string()))
508 .or_else(|| inner.get(YamlValue::String("kind".to_string())))?;
509 let stable = inner.get(YamlValue::String("stable".to_string()))?;
510 let kind = kind.as_str()?.to_string();
511 let stable = stable.as_str()?.to_string();
512 Some((
513 optional,
514 UidV5Spec {
515 type_name: kind,
516 stable,
517 },
518 ))
519}
520
521fn parse_selector_path(path: &str) -> Result<Vec<SelectorToken>> {
522 if !path.starts_with('/') {
523 return Err(anyhow!("select path must start with '/'"));
524 }
525 let mut tokens = Vec::new();
526 for segment in path.trim_start_matches('/').split('/') {
527 if segment.is_empty() {
528 continue;
529 }
530 tokens.push(parse_selector_segment(segment)?);
531 }
532 Ok(tokens)
533}
534
535fn parse_selector_segment(segment: &str) -> Result<SelectorToken> {
536 if segment == "*" {
537 return Ok(SelectorToken::Wildcard);
538 }
539 if let Ok(index) = segment.parse::<usize>() {
540 return Ok(SelectorToken::Index(index));
541 }
542 Ok(SelectorToken::Key(segment.to_string()))
543}
544
545fn parse_relative_path(path: &str) -> Result<RelativePath> {
546 let mut rest = path.trim();
547 let mut up = 0;
548 while rest.starts_with('^') {
549 up += 1;
550 rest = &rest[1..];
551 if rest.starts_with('.') {
552 rest = &rest[1..];
553 }
554 }
555 if rest.starts_with('.') {
556 rest = &rest[1..];
557 }
558 if rest.starts_with('/') {
559 rest = &rest[1..];
560 }
561 let selectors = if rest.is_empty() {
562 Vec::new()
563 } else {
564 rest.split('/')
565 .filter(|s| !s.is_empty())
566 .map(parse_selector_segment)
567 .collect::<Result<Vec<_>>>()?
568 };
569 Ok(RelativePath { up, selectors })
570}
571
572fn select_paths(
573 value: &YamlValue,
574 selectors: &[SelectorToken],
575 current_path: &mut Vec<PathToken>,
576 results: &mut Vec<Vec<PathToken>>,
577) {
578 if selectors.is_empty() {
579 results.push(current_path.clone());
580 return;
581 }
582
583 match selectors[0].clone() {
584 SelectorToken::Key(key) => {
585 if let YamlValue::Mapping(map) = value {
586 if let Some(value) = map.get(YamlValue::String(key.clone())) {
587 current_path.push(PathToken::Key(key));
588 select_paths(value, &selectors[1..], current_path, results);
589 current_path.pop();
590 }
591 }
592 }
593 SelectorToken::Index(index) => {
594 if let YamlValue::Sequence(items) = value {
595 if let Some(value) = items.get(index) {
596 current_path.push(PathToken::Index(index));
597 select_paths(value, &selectors[1..], current_path, results);
598 current_path.pop();
599 }
600 }
601 }
602 SelectorToken::Wildcard => match value {
603 YamlValue::Sequence(items) => {
604 for (index, value) in items.iter().enumerate() {
605 current_path.push(PathToken::Index(index));
606 select_paths(value, &selectors[1..], current_path, results);
607 current_path.pop();
608 }
609 }
610 YamlValue::Mapping(map) => {
611 for (key, value) in map {
612 let Some(key) = key.as_str() else {
613 continue;
614 };
615 current_path.push(PathToken::Key(key.to_string()));
616 select_paths(value, &selectors[1..], current_path, results);
617 current_path.pop();
618 }
619 }
620 _ => {}
621 },
622 }
623}
624
625fn extract_values<'a>(
626 raw: &'a YamlValue,
627 path: &[PathToken],
628 rel: &RelativePath,
629) -> Result<Vec<&'a YamlValue>> {
630 let base_path = ancestor_path(raw, path, rel.up)?;
631 let Some(base_value) = value_at_path(raw, &base_path) else {
632 return Ok(Vec::new());
633 };
634 let mut results = Vec::new();
635 select_values(base_value, &rel.selectors, &mut results);
636 Ok(results)
637}
638
639fn ancestor_path(raw: &YamlValue, path: &[PathToken], up: usize) -> Result<Vec<PathToken>> {
640 let mut current: Vec<PathToken> = path.to_vec();
641 for _ in 0..up {
642 if current.is_empty() {
643 return Err(anyhow!("relative path escapes above root"));
644 }
645 current.pop();
646 loop {
647 let Some(value) = value_at_path(raw, ¤t) else {
648 break;
649 };
650 if matches!(value, YamlValue::Sequence(_)) {
651 if current.is_empty() {
652 break;
653 }
654 current.pop();
655 } else {
656 break;
657 }
658 }
659 }
660 Ok(current)
661}
662
663fn value_at_path<'a>(value: &'a YamlValue, path: &[PathToken]) -> Option<&'a YamlValue> {
664 let mut current = value;
665 for token in path {
666 match token {
667 PathToken::Key(key) => {
668 let YamlValue::Mapping(map) = current else {
669 return None;
670 };
671 current = map.get(YamlValue::String(key.clone()))?;
672 }
673 PathToken::Index(index) => {
674 let YamlValue::Sequence(items) = current else {
675 return None;
676 };
677 current = items.get(*index)?;
678 }
679 }
680 }
681 Some(current)
682}
683
684fn select_values<'a>(
685 value: &'a YamlValue,
686 selectors: &[SelectorToken],
687 results: &mut Vec<&'a YamlValue>,
688) {
689 if selectors.is_empty() {
690 results.push(value);
691 return;
692 }
693 match selectors[0].clone() {
694 SelectorToken::Key(key) => {
695 if let YamlValue::Mapping(map) = value {
696 if let Some(value) = map.get(YamlValue::String(key)) {
697 select_values(value, &selectors[1..], results);
698 }
699 }
700 }
701 SelectorToken::Index(index) => {
702 if let YamlValue::Sequence(items) = value {
703 if let Some(value) = items.get(index) {
704 select_values(value, &selectors[1..], results);
705 }
706 }
707 }
708 SelectorToken::Wildcard => match value {
709 YamlValue::Sequence(items) => {
710 for value in items {
711 select_values(value, &selectors[1..], results);
712 }
713 }
714 YamlValue::Mapping(map) => {
715 for (key, value) in map {
716 if key.as_str().is_none() {
717 continue;
718 }
719 select_values(value, &selectors[1..], results);
720 }
721 }
722 _ => {}
723 },
724 }
725}
726
727fn yaml_to_json(value: YamlValue) -> Result<JsonValue> {
728 serde_json::to_value(value).map_err(|err| anyhow!("yaml to json failed: {err}"))
729}
730
731fn inventory_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
732 (type_name.as_str().to_string(), key_string(key))
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::planner::plan;
739 use crate::state::StateStore;
740 use crate::types::ObservedState;
741 use tempfile::tempdir;
742
743 fn parse_yaml(input: &str) -> YamlValue {
744 serde_yaml::from_str(input).unwrap()
745 }
746
747 #[test]
748 fn wildcard_selector_returns_all_nodes() {
749 let raw = parse_yaml(
750 r#"
751sites:
752 - slug: a
753 devices:
754 - name: d1
755 - name: d2
756 - slug: b
757 devices:
758 - name: d3
759"#,
760 );
761 let selectors = parse_selector_path("/sites/*/devices/*").unwrap();
762 let mut selected = Vec::new();
763 select_paths(&raw, &selectors, &mut Vec::new(), &mut selected);
764 assert_eq!(selected.len(), 3);
765 }
766
767 #[test]
768 fn templates_substitute_and_error_on_missing() {
769 let mut vars = BTreeMap::new();
770 vars.insert("name".to_string(), JsonValue::String("leaf01".to_string()));
771 let rendered = render_template("device=${name}", &vars, "devices", "key").unwrap();
772 assert_eq!(rendered, "device=leaf01");
773
774 let err = render_template("device=${missing}", &vars, "devices", "key").unwrap_err();
775 assert!(err.to_string().contains("missing var"));
776 }
777
778 #[test]
779 fn uid_v5_is_deterministic() {
780 let first = uid_v5("dcim.site", "site=fra1");
781 let second = uid_v5("dcim.site", "site=fra1");
782 assert_eq!(first, second);
783 }
784
785 #[test]
786 fn compile_raw_yaml_to_inventory() {
787 let raw = parse_yaml(
788 r#"
789sites:
790 - slug: fra1
791 name: FRA1
792 devices:
793 - name: leaf01
794 role: leaf
795 device_type: leaf-switch
796 model:
797 fabric: fra1-fabric
798 role_hint: leaf
799 tags:
800 - fabric
801 interfaces:
802 - name: eth0
803 - name: eth1
804prefixes:
805 - site: fra1
806 prefix: 10.0.0.0/24
807 ips:
808 - device: leaf01
809 interface: eth0
810 address: 10.0.0.10/24
811"#,
812 );
813 let retort = parse_yaml(include_str!("../../../examples/retort.yaml"));
814 let retort: Retort = serde_yaml::from_value(retort).unwrap();
815 let inventory = compile_retort(&raw, &retort).unwrap();
816 let json = serde_json::to_value(&inventory).unwrap();
817 let objects = json.get("objects").unwrap().as_array().unwrap();
818 assert_eq!(objects.len(), 6);
819 let types: Vec<&str> = objects
820 .iter()
821 .map(|obj| obj.get("type").unwrap().as_str().unwrap())
822 .collect();
823 assert!(types.contains(&"dcim.site"));
824 assert!(types.contains(&"dcim.device"));
825 }
826
827 #[test]
828 fn plan_is_deterministic_across_runs() {
829 let raw = parse_yaml(
830 r#"
831sites:
832 - slug: fra1
833 name: FRA1
834"#,
835 );
836 let retort = parse_yaml(
837 r#"
838version: 1
839schema:
840 types:
841 dcim.site:
842 key:
843 site:
844 type: slug
845 fields:
846 name:
847 type: string
848 slug:
849 type: slug
850rules:
851 - name: sites
852 select: /sites/*
853 emit:
854 type: dcim.site
855 key:
856 site: "${slug}"
857 vars:
858 slug: { from: .slug, required: true }
859 name: { from: .name, required: true }
860 attrs:
861 name: ${name}
862 slug: ${slug}
863"#,
864 );
865 let retort: Retort = serde_yaml::from_value(retort).unwrap();
866 let inventory = compile_retort(&raw, &retort).unwrap();
867 let state = StateStore::load(tempdir().unwrap().path().join("state.json")).unwrap();
868 let observed = ObservedState::default();
869 let first = plan(
870 &inventory.objects,
871 &observed,
872 &state,
873 &inventory.schema,
874 false,
875 );
876 let second = plan(
877 &inventory.objects,
878 &observed,
879 &state,
880 &inventory.schema,
881 false,
882 );
883 assert_eq!(first.ops, second.ops);
884 }
885
886 #[test]
887 fn parse_relative_path_tracks_parent_hops() {
888 let rel = parse_relative_path("^^.slug").unwrap();
889 assert_eq!(rel.up, 2);
890 assert_eq!(rel.selectors.len(), 1);
891 }
892
893 #[test]
894 fn render_uid_mapping_optional_skips_missing() {
895 let vars = BTreeMap::new();
896 let mapping: YamlValue = serde_yaml::from_str(
897 r#"
898uid?:
899 type: "dcim.site"
900 stable: "site=${slug}"
901"#,
902 )
903 .unwrap();
904 let rendered = render_yaml_value(&mapping, &vars, "rule", "attrs", false).unwrap();
905 assert!(rendered.is_none());
906 }
907
908 #[test]
909 fn render_uid_mapping_required_errors_on_missing() {
910 let vars = BTreeMap::new();
911 let mapping: YamlValue = serde_yaml::from_str(
912 r#"
913uid:
914 type: "dcim.site"
915 stable: "site=${slug}"
916"#,
917 )
918 .unwrap();
919 let err = render_yaml_value(&mapping, &vars, "rule", "attrs", false).unwrap_err();
920 assert!(err.to_string().contains("missing var"));
921 }
922
923 #[test]
924 fn template_errors_on_non_string_var() {
925 let mut vars = BTreeMap::new();
926 vars.insert("asn".to_string(), JsonValue::Number(65001.into()));
927 let err = render_template("asn=${asn}", &vars, "rule", "key").unwrap_err();
928 assert!(err.to_string().contains("must be a string"));
929 }
930
931 #[test]
932 fn resolve_uid_template_rejects_invalid_uuid() {
933 let vars = BTreeMap::new();
934 let err = resolve_uid_template("not-a-uuid", &vars, "rule").unwrap_err();
935 assert!(err.to_string().contains("uid template is not a valid uuid"));
936 }
937
938 #[test]
939 fn multi_emit_produces_multiple_objects() {
940 let raw = parse_yaml(
941 r#"
942fabrics:
943 - name: fabric1
944 site_slug: fra1
945 vrf_name: blue
946"#,
947 );
948 let retort = parse_yaml(
949 r#"
950version: 1
951schema:
952 types:
953 dcim.site:
954 key:
955 site:
956 type: slug
957 fields:
958 name:
959 type: string
960 slug:
961 type: slug
962 custom.vrf:
963 key:
964 vrf:
965 type: slug
966 fields:
967 name:
968 type: string
969rules:
970 - name: fabric
971 select: /fabrics/*
972 vars:
973 site_slug: { from: .site_slug, required: true }
974 vrf_name: { from: .vrf_name, required: true }
975 emit:
976 - type: dcim.site
977 key:
978 site: "${site_slug}"
979 attrs:
980 name: ${site_slug}
981 slug: ${site_slug}
982 - type: custom.vrf
983 key:
984 vrf: "${vrf_name}"
985 attrs:
986 name: ${vrf_name}
987"#,
988 );
989 let retort: Retort = serde_yaml::from_value(retort).unwrap();
990 let inventory = compile_retort(&raw, &retort).unwrap();
991 assert_eq!(inventory.objects.len(), 2);
992 assert_eq!(inventory.objects[0].type_name.as_str(), "custom.vrf");
994 assert_eq!(inventory.objects[1].type_name.as_str(), "dcim.site");
995 }
996
997 #[test]
998 fn multi_emit_with_named_uids() {
999 let raw = parse_yaml(
1000 r#"
1001fabrics:
1002 - site_slug: fra1
1003 vrf_name: blue
1004"#,
1005 );
1006 let retort = parse_yaml(
1007 r#"
1008version: 1
1009schema:
1010 types:
1011 dcim.site:
1012 key:
1013 site:
1014 type: slug
1015 fields:
1016 name:
1017 type: string
1018 slug:
1019 type: slug
1020 custom.vrf:
1021 key:
1022 vrf:
1023 type: slug
1024 fields:
1025 name:
1026 type: string
1027 site:
1028 type: ref
1029 target: dcim.site
1030rules:
1031 - name: fabric
1032 select: /fabrics/*
1033 vars:
1034 site_slug: { from: .site_slug, required: true }
1035 vrf_name: { from: .vrf_name, required: true }
1036 uids:
1037 site:
1038 v5:
1039 type: "dcim.site"
1040 stable: "site=${site_slug}"
1041 emit:
1042 - type: dcim.site
1043 key:
1044 site: "${site_slug}"
1045 uid: ${uids.site}
1046 attrs:
1047 name: ${site_slug}
1048 slug: ${site_slug}
1049 - type: custom.vrf
1050 key:
1051 vrf: "${vrf_name}"
1052 attrs:
1053 name: ${vrf_name}
1054 site: ${uids.site}
1055"#,
1056 );
1057 let retort: Retort = serde_yaml::from_value(retort).unwrap();
1058 let inventory = compile_retort(&raw, &retort).unwrap();
1059 assert_eq!(inventory.objects.len(), 2);
1060
1061 let mut site = None;
1062 let mut vrf = None;
1063 for object in &inventory.objects {
1064 match object.type_name.as_str() {
1065 "dcim.site" => site = Some(object),
1066 "custom.vrf" => vrf = Some(object),
1067 _ => {}
1068 }
1069 }
1070 let site = site.expect("expected dcim.site");
1071 let vrf = vrf.expect("expected custom.vrf");
1072
1073 let expected_site_uid = uid_v5("dcim.site", "site=fra1");
1075 assert_eq!(site.uid, expected_site_uid);
1076
1077 let vrf_attrs = &vrf.attrs;
1079 let site_ref = vrf_attrs.get("site").unwrap().as_str().unwrap();
1080 assert_eq!(site_ref, expected_site_uid.to_string());
1081 }
1082
1083 #[test]
1084 fn multi_emit_is_deterministic() {
1085 let raw = parse_yaml(
1086 r#"
1087fabrics:
1088 - site_slug: fra1
1089 vrf_name: blue
1090 - site_slug: fra2
1091 vrf_name: red
1092"#,
1093 );
1094 let retort = parse_yaml(
1095 r#"
1096version: 1
1097schema:
1098 types:
1099 dcim.site:
1100 key:
1101 site:
1102 type: slug
1103 fields:
1104 slug:
1105 type: slug
1106 custom.vrf:
1107 key:
1108 vrf:
1109 type: slug
1110 fields:
1111 name:
1112 type: string
1113rules:
1114 - name: fabric
1115 select: /fabrics/*
1116 vars:
1117 site_slug: { from: .site_slug, required: true }
1118 vrf_name: { from: .vrf_name, required: true }
1119 emit:
1120 - type: dcim.site
1121 key:
1122 site: "${site_slug}"
1123 attrs:
1124 slug: ${site_slug}
1125 - type: custom.vrf
1126 key:
1127 vrf: "${vrf_name}"
1128 attrs:
1129 name: ${vrf_name}
1130"#,
1131 );
1132 let retort: Retort = serde_yaml::from_value(retort).unwrap();
1133 let first = compile_retort(&raw, &retort).unwrap();
1134 let second = compile_retort(&raw, &retort).unwrap();
1135
1136 assert_eq!(first.objects.len(), 4);
1137 assert_eq!(first.objects.len(), second.objects.len());
1138 for (a, b) in first.objects.iter().zip(second.objects.iter()) {
1139 assert_eq!(a.uid, b.uid);
1140 assert_eq!(a.type_name, b.type_name);
1141 assert_eq!(a.key, b.key);
1142 }
1143 }
1144
1145 #[test]
1146 fn emit_level_vars_override_rule_level() {
1147 let raw = parse_yaml(
1148 r#"
1149items:
1150 - name: item1
1151 override_name: overridden
1152"#,
1153 );
1154 let retort = parse_yaml(
1155 r#"
1156version: 1
1157schema:
1158 types:
1159 custom.first:
1160 key:
1161 first:
1162 type: slug
1163 fields:
1164 name:
1165 type: string
1166 custom.second:
1167 key:
1168 second:
1169 type: slug
1170 fields:
1171 name:
1172 type: string
1173rules:
1174 - name: items
1175 select: /items/*
1176 vars:
1177 name: { from: .name, required: true }
1178 emit:
1179 - type: custom.first
1180 key:
1181 first: "${name}"
1182 attrs:
1183 name: ${name}
1184 - type: custom.second
1185 key:
1186 second: "${name}"
1187 vars:
1188 name: { from: .override_name, required: true }
1189 attrs:
1190 name: ${name}
1191"#,
1192 );
1193 let retort: Retort = serde_yaml::from_value(retort).unwrap();
1194 let inventory = compile_retort(&raw, &retort).unwrap();
1195 assert_eq!(inventory.objects.len(), 2);
1196
1197 let first = &inventory.objects[0];
1198 let second = &inventory.objects[1];
1199
1200 let first_attrs = &first.attrs;
1202 assert_eq!(first_attrs.get("name").unwrap().as_str().unwrap(), "item1");
1203
1204 let second_attrs = &second.attrs;
1206 assert_eq!(
1207 second_attrs.get("name").unwrap().as_str().unwrap(),
1208 "overridden"
1209 );
1210 }
1211}