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