1#![cfg_attr(docsrs, feature(doc_cfg))]
2pub mod builder;
15pub mod cli;
16pub mod collections;
17pub mod config;
18pub mod devices;
19pub mod filter;
20pub mod getters;
21pub mod inventory;
22pub mod nornir;
23pub mod pipeline;
24pub mod render;
25pub mod sdk;
26pub mod secrets;
27pub mod tasks;
28pub mod textfsm;
29pub mod transport;
30pub mod util;
31pub mod vars;
32pub mod vault;
33
34use crate::util::env::{self, InterpolationMeta};
35use clap::ValueEnum;
36use indexmap::IndexMap;
37use reqwest::Url;
38use serde::{
39 Deserialize, Serialize,
40 de::{self, DeserializeOwned, Deserializer, Error as DeError},
41};
42use std::{
43 collections::HashMap,
44 fmt, fs,
45 path::{Path, PathBuf},
46 time::Duration,
47};
48
49#[derive(Debug, Clone, Copy, ValueEnum)]
51pub enum CliMode {
52 Exec,
54 Enable,
56 Config,
58}
59
60impl<'de> serde::Deserialize<'de> for CliMode {
61 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62 where
63 D: Deserializer<'de>,
64 {
65 let raw = String::deserialize(deserializer)?;
66 CliMode::from_str(&raw, true).map_err(DeError::custom)
67 }
68}
69
70pub type Variables = HashMap<String, serde_json::Value>;
88
89pub type OrderedMap<K, V> = IndexMap<K, V>;
91pub type HostMap = OrderedMap<String, Host>;
93pub type GroupMap = OrderedMap<String, Group>;
95
96#[derive(Deserialize)]
98#[serde(untagged)]
99enum HostEntries {
100 Map(HostMap),
101 Seq(Vec<Host>),
102}
103
104fn normalize_host_entries(entries: HostEntries) -> Result<HostMap, String> {
107 match entries {
108 HostEntries::Map(map) => {
109 let mut normalized = HostMap::new();
110 for (key, mut host) in map {
111 if host.name.is_empty() {
112 host.name = key.clone();
113 }
114 if host.name.is_empty() {
115 return Err("host entry missing name".into());
116 }
117 normalized.insert(host.name.clone(), host);
118 }
119 Ok(normalized)
120 }
121 HostEntries::Seq(list) => {
122 let mut normalized = HostMap::new();
123 for host in list {
124 if host.name.is_empty() {
125 return Err("host entry missing name".into());
126 }
127 normalized.insert(host.name.clone(), host);
128 }
129 Ok(normalized)
130 }
131 }
132}
133
134fn hosts_from_any<'de, D>(deserializer: D) -> Result<HostMap, D::Error>
136where
137 D: Deserializer<'de>,
138{
139 let entries = Option::<HostEntries>::deserialize(deserializer)?;
140 match entries {
141 Some(inner) => normalize_host_entries(inner).map_err(de::Error::custom),
142 None => Ok(HostMap::new()),
143 }
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct Inventory {
221 #[serde(default, deserialize_with = "hosts_from_any")]
223 pub hosts: HostMap,
224 #[serde(default)]
226 pub groups: GroupMap,
227 #[serde(default)]
229 pub defaults: Defaults,
230}
231
232impl Inventory {
233 pub fn host(&self, name: &str) -> Option<&Host> {
238 self.hosts.get(name)
239 }
240
241 pub fn merge(&mut self, other: Inventory) {
255 for (name, host) in other.hosts {
256 match self.hosts.get_mut(&name) {
257 Some(existing) => existing.merge(host),
258 None => {
259 self.hosts.insert(name, host);
260 }
261 }
262 }
263
264 for (name, group) in other.groups {
265 match self.groups.get_mut(&name) {
266 Some(existing) => existing.merge(group),
267 None => {
268 self.groups.insert(name, group);
269 }
270 }
271 }
272
273 self.defaults.merge(other.defaults);
274 }
275
276 fn apply_inheritance(&mut self) {
279 let defaults = self.defaults.clone();
280 let groups = self.groups.clone();
281 for host in self.hosts.values_mut() {
282 let original_creds = host.credentials.clone();
283 let original_data = host.data.clone();
284 let original_connections = host.connections.clone();
285
286 apply_defaults(&defaults, host);
287 apply_group_layers(&groups, host);
288
289 if let Some(creds) = original_creds {
290 match host.credentials.as_mut() {
291 Some(existing) => existing.merge(creds),
292 None => host.credentials = Some(creds),
293 }
294 }
295 merge_connections(&mut host.connections, original_connections);
296 merge_variables(&mut host.data, original_data);
297 }
298 }
299}
300
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct Host {
309 #[serde(default)]
311 pub name: String,
312 pub hostname: Option<String>,
314 pub platform: Option<String>,
316 pub port: Option<u16>,
318 #[serde(default)]
320 pub groups: Vec<String>,
321 pub credentials: Option<Credentials>,
323 #[serde(default)]
325 pub connections: HashMap<String, TransportSettings>,
326 #[serde(default)]
328 pub data: Variables,
329 pub config_store: Option<ConfigStoreOverride>,
331}
332
333impl Host {
334 fn merge(&mut self, other: Host) {
336 if self.name.is_empty() {
337 self.name = other.name;
338 }
339 if other.hostname.is_some() {
340 self.hostname = other.hostname;
341 }
342 if other.platform.is_some() {
343 self.platform = other.platform;
344 }
345 if other.port.is_some() {
346 self.port = other.port;
347 }
348 for group in other.groups {
349 if !self.groups.contains(&group) {
350 self.groups.push(group);
351 }
352 }
353 if let Some(creds) = other.credentials {
354 match self.credentials.as_mut() {
355 Some(existing) => existing.merge(creds),
356 None => self.credentials = Some(creds),
357 }
358 }
359 merge_connections(&mut self.connections, other.connections);
360 merge_variables(&mut self.data, other.data);
361 merge_config_store(&mut self.config_store, other.config_store);
362 }
363}
364
365#[derive(Debug, Clone, Default, Serialize, Deserialize)]
367pub struct ConfigStoreOverride {
368 pub command: Option<String>,
369 pub default_command: Option<String>,
370}
371
372impl ConfigStoreOverride {
373 fn merge(&mut self, other: ConfigStoreOverride) {
374 if other.command.is_some() {
375 self.command = other.command;
376 }
377 if other.default_command.is_some() {
378 self.default_command = other.default_command;
379 }
380 }
381}
382
383fn merge_config_store(
385 target: &mut Option<ConfigStoreOverride>,
386 incoming: Option<ConfigStoreOverride>,
387) {
388 if let Some(incoming) = incoming {
389 match target {
390 Some(existing) => existing.merge(incoming),
391 None => *target = Some(incoming),
392 }
393 }
394}
395
396fn apply_defaults(defaults: &Defaults, host: &mut Host) {
398 if let Some(creds) = defaults.credentials.clone() {
399 match host.credentials.as_mut() {
400 Some(existing) => existing.merge(creds),
401 None => host.credentials = Some(creds),
402 }
403 }
404 merge_connections(&mut host.connections, defaults.connections.clone());
405 merge_variables(&mut host.data, defaults.data.clone());
406 merge_config_store(&mut host.config_store, defaults.config_store.clone());
407}
408
409fn apply_group_layers(groups: &GroupMap, host: &mut Host) {
411 let mut visited = std::collections::HashSet::new();
412 for group_name in host.groups.clone() {
413 apply_group_recursive(host, &group_name, groups, &mut visited);
414 }
415}
416
417fn apply_group_recursive(
419 host: &mut Host,
420 name: &str,
421 groups: &GroupMap,
422 visited: &mut std::collections::HashSet<String>,
423) {
424 if !visited.insert(name.to_string()) {
425 return;
426 }
427 let Some(group) = groups.get(name) else {
428 return;
429 };
430
431 for parent in &group.parents {
432 apply_group_recursive(host, parent, groups, visited);
433 }
434
435 if let Some(creds) = group.credentials.clone() {
436 match host.credentials.as_mut() {
437 Some(existing) => existing.merge(creds),
438 None => host.credentials = Some(creds),
439 }
440 }
441 merge_connections(&mut host.connections, group.connections.clone());
442 merge_variables(&mut host.data, group.data.clone());
443 merge_config_store(&mut host.config_store, group.config_store.clone());
444}
445
446#[derive(Debug, Clone, Default, Serialize, Deserialize)]
453pub struct Group {
454 #[serde(default)]
456 pub name: String,
457 #[serde(default)]
459 pub parents: Vec<String>,
460 pub credentials: Option<Credentials>,
462 #[serde(default)]
464 pub connections: HashMap<String, TransportSettings>,
465 #[serde(default)]
467 pub data: Variables,
468 pub config_store: Option<ConfigStoreOverride>,
470}
471
472impl Group {
473 fn merge(&mut self, other: Group) {
474 if self.name.is_empty() {
475 self.name = other.name;
476 }
477 for parent in other.parents {
478 if !self.parents.contains(&parent) {
479 self.parents.push(parent);
480 }
481 }
482 if let Some(creds) = other.credentials {
483 match self.credentials.as_mut() {
484 Some(existing) => existing.merge(creds),
485 None => self.credentials = Some(creds),
486 }
487 }
488 merge_connections(&mut self.connections, other.connections);
489 merge_variables(&mut self.data, other.data);
490 merge_config_store(&mut self.config_store, other.config_store);
491 }
492}
493
494#[derive(Debug, Clone, Default, Serialize, Deserialize)]
501pub struct Defaults {
502 pub credentials: Option<Credentials>,
504 #[serde(default)]
506 pub connections: HashMap<String, TransportSettings>,
507 #[serde(default)]
509 pub data: Variables,
510 pub config_store: Option<ConfigStoreOverride>,
512}
513
514impl Defaults {
515 fn merge(&mut self, other: Defaults) {
516 if let Some(creds) = other.credentials {
517 match self.credentials.as_mut() {
518 Some(existing) => existing.merge(creds),
519 None => self.credentials = Some(creds),
520 }
521 }
522 merge_connections(&mut self.connections, other.connections);
523 merge_variables(&mut self.data, other.data);
524 }
525}
526
527#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532pub struct Credentials {
533 pub username: Option<String>,
535 pub password: Option<Secret>,
537 pub private_key: Option<Secret>,
539 pub enable_secret: Option<Secret>,
541}
542
543impl Credentials {
544 fn merge(&mut self, other: Credentials) {
545 if other.username.is_some() {
546 self.username = other.username;
547 }
548 if other.password.is_some() {
549 self.password = other.password;
550 }
551 if other.private_key.is_some() {
552 self.private_key = other.private_key;
553 }
554 if other.enable_secret.is_some() {
555 self.enable_secret = other.enable_secret;
556 }
557 }
558}
559
560#[derive(Debug, Clone, Serialize, Default)]
568pub struct Secret {
569 #[serde(skip_serializing_if = "Option::is_none")]
571 pub value: Option<String>,
572 #[serde(default)]
574 pub encrypted: bool,
575 #[serde(skip_serializing_if = "Option::is_none")]
577 pub reference: Option<SecretReference>,
578}
579
580impl Secret {
581 pub fn new(value: impl Into<String>) -> Self {
583 Self {
584 value: Some(value.into()),
585 encrypted: false,
586 reference: None,
587 }
588 }
589
590 pub fn as_str(&self) -> Option<&str> {
592 self.value.as_deref()
593 }
594}
595
596#[derive(Deserialize)]
597#[serde(untagged)]
598enum SecretRepr {
599 String(String),
600 Map(SecretFields),
601}
602
603#[derive(Deserialize, Default)]
604struct SecretFields {
605 #[serde(default)]
606 value: Option<String>,
607 #[serde(default)]
608 encrypted: bool,
609 #[serde(rename = "ref")]
610 reference_key: Option<String>,
611 #[serde(default)]
612 provider: Option<String>,
613 #[serde(default)]
614 optional: Option<bool>,
615 #[serde(default)]
616 reference: Option<SecretReferenceRepr>,
617}
618
619#[derive(Deserialize)]
620#[serde(untagged)]
621enum SecretReferenceRepr {
622 String(String),
623 Map(SecretReferenceFields),
624}
625
626#[derive(Deserialize, Default)]
627struct SecretReferenceFields {
628 #[serde(rename = "ref")]
629 key_ref: Option<String>,
630 #[serde(default)]
631 key: Option<String>,
632 #[serde(default)]
633 provider: Option<String>,
634 #[serde(default)]
635 optional: Option<bool>,
636}
637
638impl SecretReferenceRepr {
639 fn into_reference(self) -> Result<SecretReference, String> {
640 match self {
641 SecretReferenceRepr::String(key) => Ok(SecretReference {
642 provider: None,
643 key,
644 optional: false,
645 }),
646 SecretReferenceRepr::Map(fields) => {
647 let key = fields
648 .key
649 .or(fields.key_ref)
650 .ok_or_else(|| "reference requires a key".to_string())?;
651 Ok(SecretReference {
652 provider: fields.provider,
653 key,
654 optional: fields.optional.unwrap_or(false),
655 })
656 }
657 }
658 }
659}
660
661impl<'de> Deserialize<'de> for Secret {
662 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
663 where
664 D: Deserializer<'de>,
665 {
666 match SecretRepr::deserialize(deserializer)? {
667 SecretRepr::String(value) => Ok(Secret {
668 value: Some(value),
669 encrypted: false,
670 reference: None,
671 }),
672 SecretRepr::Map(fields) => {
673 let mut reference = if let Some(raw) = fields.reference {
674 Some(raw.into_reference().map_err(de::Error::custom)?)
675 } else {
676 None
677 };
678 if reference.is_none() {
679 if let Some(key) = fields.reference_key.clone() {
680 reference = Some(SecretReference {
681 provider: fields.provider.clone(),
682 key,
683 optional: fields.optional.unwrap_or(false),
684 });
685 }
686 } else if let Some(ref mut reference) = reference {
687 if reference.provider.is_none() {
688 reference.provider = fields.provider.clone();
689 }
690 if let Some(optional) = fields.optional {
691 reference.optional = optional;
692 }
693 }
694 if fields.provider.is_some() && reference.is_none() {
695 return Err(de::Error::custom(
696 "secret provider requires a matching ref".to_string(),
697 ));
698 }
699 if fields.value.is_none() && reference.is_none() {
700 return Err(de::Error::custom(
701 "secret requires either value or ref".to_string(),
702 ));
703 }
704 Ok(Secret {
705 value: fields.value,
706 encrypted: fields.encrypted,
707 reference,
708 })
709 }
710 }
711 }
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct SecretReference {
716 #[serde(skip_serializing_if = "Option::is_none")]
718 pub provider: Option<String>,
719 #[serde(rename = "ref")]
721 pub key: String,
722 #[serde(default)]
724 pub optional: bool,
725}
726
727impl SecretReference {
728 pub fn new(key: impl Into<String>) -> Self {
730 Self {
731 provider: None,
732 key: key.into(),
733 optional: false,
734 }
735 }
736}
737
738#[derive(Debug, Clone, Default, Serialize, Deserialize)]
746pub struct TransportSettings {
747 pub transport: String,
749 #[serde(default)]
751 pub params: Variables,
752}
753
754impl TransportSettings {
755 pub fn params_as<T>(&self) -> Result<T, serde_json::Error>
787 where
788 T: DeserializeOwned,
789 {
790 let map = serde_json::Map::from_iter(self.params.clone());
791 serde_json::from_value(serde_json::Value::Object(map))
792 }
793}
794
795fn merge_connections(
797 existing: &mut HashMap<String, TransportSettings>,
798 incoming: HashMap<String, TransportSettings>,
799) {
800 for (name, mut settings) in incoming {
801 match existing.get_mut(&name) {
802 Some(current) => {
803 if settings.transport.is_empty() {
804 settings.transport = current.transport.clone();
805 }
806 if !settings.transport.is_empty() {
807 current.transport = settings.transport;
808 }
809 merge_variables(&mut current.params, settings.params);
810 }
811 None => {
812 existing.insert(name, settings);
813 }
814 }
815 }
816}
817
818fn merge_variables(existing: &mut Variables, incoming: Variables) {
820 for (k, v) in incoming {
821 existing.insert(k, v);
822 }
823}
824
825#[derive(Debug)]
832pub enum InventoryError {
833 Io(std::io::Error),
835 Http(reqwest::Error),
837 Parse(String),
839}
840
841impl fmt::Display for InventoryError {
842 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
843 match self {
844 InventoryError::Io(err) => write!(f, "io error: {}", err),
845 InventoryError::Http(err) => write!(f, "http error: {}", err),
846 InventoryError::Parse(msg) => write!(f, "parse error: {}", msg),
847 }
848 }
849}
850
851impl std::error::Error for InventoryError {
852 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
853 match self {
854 InventoryError::Io(err) => Some(err),
855 InventoryError::Http(err) => Some(err),
856 InventoryError::Parse(_) => None,
857 }
858 }
859}
860
861impl From<std::io::Error> for InventoryError {
862 fn from(value: std::io::Error) -> Self {
863 InventoryError::Io(value)
864 }
865}
866
867impl From<reqwest::Error> for InventoryError {
868 fn from(value: reqwest::Error) -> Self {
869 InventoryError::Http(value)
870 }
871}
872
873pub trait InventorySource: Send + Sync {
882 fn load(&self) -> Result<Inventory, InventoryError>;
884
885 fn name(&self) -> &str;
887}
888
889#[derive(Default)]
920pub struct InventoryBuilder {
921 sources: Vec<Box<dyn InventorySource>>,
922}
923
924impl InventoryBuilder {
925 pub fn new() -> Self {
927 Self::default()
928 }
929
930 pub fn with_source(mut self, source: impl InventorySource + 'static) -> Self {
943 self.sources.push(Box::new(source));
944 self
945 }
946
947 pub fn add_source(&mut self, source: impl InventorySource + 'static) {
952 self.sources.push(Box::new(source));
953 }
954
955 pub fn build(mut self) -> Result<Inventory, InventoryError> {
962 let mut inventory = Inventory::default();
963 for source in self.sources.drain(..) {
964 let data = source.load()?;
965 inventory.merge(data);
966 }
967 inventory.apply_inheritance();
968 Ok(inventory)
969 }
970}
971
972pub struct StaticInventorySource {
995 name: String,
996 inventory: Inventory,
997}
998
999impl StaticInventorySource {
1000 pub fn new(name: impl Into<String>, inventory: Inventory) -> Self {
1002 Self {
1003 name: name.into(),
1004 inventory,
1005 }
1006 }
1007}
1008
1009impl InventorySource for StaticInventorySource {
1010 fn load(&self) -> Result<Inventory, InventoryError> {
1011 Ok(self.inventory.clone())
1012 }
1013
1014 fn name(&self) -> &str {
1015 &self.name
1016 }
1017}
1018
1019#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1025pub enum InventoryFormat {
1026 Yaml,
1027 Json,
1028 Toml,
1029}
1030
1031impl InventoryFormat {
1032 fn from_path(path: &Path) -> Option<Self> {
1034 path.extension()
1035 .and_then(|ext| ext.to_str())
1036 .and_then(Self::from_label)
1037 }
1038
1039 pub fn from_extension(ext: &str) -> Option<Self> {
1041 match ext {
1042 "yaml" | "yml" => Some(InventoryFormat::Yaml),
1043 "json" => Some(InventoryFormat::Json),
1044 "toml" => Some(InventoryFormat::Toml),
1045 _ => None,
1046 }
1047 }
1048
1049 pub fn from_label(label: &str) -> Option<Self> {
1051 let lowered = label.trim().to_ascii_lowercase();
1052 Self::from_extension(lowered.as_str())
1053 }
1054}
1055
1056pub struct FileInventorySource {
1087 path: PathBuf,
1088 format: InventoryFormat,
1089 name: String,
1090}
1091
1092impl FileInventorySource {
1093 pub fn infer(path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1095 let path = path.into();
1096 let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1097 InventoryError::Parse(format!("unsupported file extension for {}", path.display()))
1098 })?;
1099
1100 Ok(Self {
1101 name: path.display().to_string(),
1102 path,
1103 format,
1104 })
1105 }
1106
1107 pub fn new(path: impl Into<PathBuf>, format: InventoryFormat) -> Self {
1109 let path = path.into();
1110 Self {
1111 name: path.display().to_string(),
1112 path,
1113 format,
1114 }
1115 }
1116}
1117
1118fn io_error_with_path(err: std::io::Error, path: &Path) -> std::io::Error {
1120 let message = format!("{} ({})", err, path.display());
1121 std::io::Error::new(err.kind(), message)
1122}
1123
1124impl InventorySource for FileInventorySource {
1125 fn load(&self) -> Result<Inventory, InventoryError> {
1126 let raw = fs::read_to_string(&self.path)
1127 .map_err(|err| InventoryError::Io(io_error_with_path(err, &self.path)))?;
1128 let meta = InterpolationMeta::for_path(&self.path);
1129 let processed = env::interpolate_with_meta(&raw, Some(&meta))
1130 .map_err(|err| InventoryError::Parse(err.to_string()))?;
1131 let inventory = match self.format {
1132 InventoryFormat::Yaml => serde_yaml::from_str(&processed)
1133 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1134 InventoryFormat::Json => serde_json::from_str(&processed)
1135 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1136 InventoryFormat::Toml => {
1137 toml::from_str(&processed).map_err(|err| InventoryError::Parse(err.to_string()))?
1138 }
1139 };
1140 Ok(inventory)
1141 }
1142
1143 fn name(&self) -> &str {
1144 &self.name
1145 }
1146}
1147
1148pub struct CompositeFileInventorySource {
1194 hosts: Option<(PathBuf, InventoryFormat)>,
1195 groups: Option<(PathBuf, InventoryFormat)>,
1196 defaults: Option<(PathBuf, InventoryFormat)>,
1197 name: String,
1198}
1199
1200impl Default for CompositeFileInventorySource {
1201 fn default() -> Self {
1202 Self::new()
1203 }
1204}
1205
1206impl CompositeFileInventorySource {
1207 pub fn new() -> Self {
1209 Self {
1210 hosts: None,
1211 groups: None,
1212 defaults: None,
1213 name: "composite-files".into(),
1214 }
1215 }
1216
1217 pub fn hosts(mut self, path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1219 let path = path.into();
1220 let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1221 InventoryError::Parse(format!(
1222 "unsupported host file extension for {}",
1223 path.display()
1224 ))
1225 })?;
1226 self.hosts = Some((path, format));
1227 Ok(self)
1228 }
1229
1230 pub fn groups(mut self, path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1232 let path = path.into();
1233 let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1234 InventoryError::Parse(format!(
1235 "unsupported group file extension for {}",
1236 path.display()
1237 ))
1238 })?;
1239 self.groups = Some((path, format));
1240 Ok(self)
1241 }
1242
1243 pub fn defaults(mut self, path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1245 let path = path.into();
1246 let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1247 InventoryError::Parse(format!(
1248 "unsupported defaults file extension for {}",
1249 path.display()
1250 ))
1251 })?;
1252 self.defaults = Some((path, format));
1253 Ok(self)
1254 }
1255}
1256
1257impl InventorySource for CompositeFileInventorySource {
1258 fn load(&self) -> Result<Inventory, InventoryError> {
1259 let mut inventory = Inventory::default();
1260
1261 if let Some((path, format)) = &self.hosts {
1262 let raw = fs::read_to_string(path)
1263 .map_err(|err| InventoryError::Io(io_error_with_path(err, path)))?;
1264 let meta = InterpolationMeta::for_path(path);
1265 let processed = env::interpolate_with_meta(&raw, Some(&meta))
1266 .map_err(|err| InventoryError::Parse(err.to_string()))?;
1267 let hosts = parse_hosts_document(&processed, *format)?;
1268 inventory.hosts.extend(hosts);
1269 }
1270
1271 if let Some((path, format)) = &self.groups {
1272 let groups_map = fs::read_to_string(path)
1273 .map_err(|err| InventoryError::Io(io_error_with_path(err, path)))?;
1274 let meta = InterpolationMeta::for_path(path);
1275 let groups_map = env::interpolate_with_meta(&groups_map, Some(&meta))
1276 .map_err(|err| InventoryError::Parse(err.to_string()))?;
1277 let groups: GroupMap = match format {
1278 InventoryFormat::Yaml => serde_yaml::from_str(&groups_map)
1279 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1280 InventoryFormat::Json => serde_json::from_str(&groups_map)
1281 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1282 InventoryFormat::Toml => toml::from_str(&groups_map)
1283 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1284 };
1285 for (name, mut group) in groups.into_iter() {
1286 if group.name.is_empty() {
1287 group.name = name.clone();
1288 }
1289 inventory.groups.insert(name, group);
1290 }
1291 }
1292
1293 if let Some((path, format)) = &self.defaults {
1294 let raw = fs::read_to_string(path)
1295 .map_err(|err| InventoryError::Io(io_error_with_path(err, path)))?;
1296 let meta = InterpolationMeta::for_path(path);
1297 let raw = env::interpolate_with_meta(&raw, Some(&meta))
1298 .map_err(|err| InventoryError::Parse(err.to_string()))?;
1299 let defaults: Defaults = match format {
1300 InventoryFormat::Yaml => serde_yaml::from_str(&raw)
1301 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1302 InventoryFormat::Json => serde_json::from_str(&raw)
1303 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1304 InventoryFormat::Toml => {
1305 toml::from_str(&raw).map_err(|err| InventoryError::Parse(err.to_string()))?
1306 }
1307 };
1308 inventory.defaults = defaults;
1309 }
1310
1311 Ok(inventory)
1312 }
1313
1314 fn name(&self) -> &str {
1315 &self.name
1316 }
1317}
1318
1319fn parse_hosts_document(raw: &str, format: InventoryFormat) -> Result<HostMap, InventoryError> {
1321 let entries = match format {
1322 InventoryFormat::Yaml => serde_yaml::from_str::<HostEntries>(raw)
1323 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1324 InventoryFormat::Json => serde_json::from_str::<HostEntries>(raw)
1325 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1326 InventoryFormat::Toml => toml::from_str::<HostEntries>(raw)
1327 .map_err(|err| InventoryError::Parse(err.to_string()))?,
1328 };
1329 normalize_host_entries(entries).map_err(InventoryError::Parse)
1330}
1331
1332pub struct HttpInventorySource {
1353 client: reqwest::blocking::Client,
1354 url: String,
1355 format: InventoryFormat,
1356 headers: Vec<(String, String)>,
1357 auth: Option<HttpAuth>,
1358}
1359
1360#[derive(Debug, Clone)]
1361enum HttpAuth {
1362 Basic { username: String, password: String },
1363 Bearer { token: String },
1364}
1365
1366impl HttpInventorySource {
1367 pub fn new(url: impl Into<String>, format: InventoryFormat) -> Result<Self, InventoryError> {
1369 Ok(Self {
1370 client: reqwest::blocking::Client::new(),
1371 url: url.into(),
1372 format,
1373 headers: Vec::new(),
1374 auth: None,
1375 })
1376 }
1377
1378 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1380 self.headers.push((key.into(), value.into()));
1381 self
1382 }
1383
1384 pub fn with_basic_auth(
1386 mut self,
1387 username: impl Into<String>,
1388 password: impl Into<String>,
1389 ) -> Self {
1390 self.auth = Some(HttpAuth::Basic {
1391 username: username.into(),
1392 password: password.into(),
1393 });
1394 self
1395 }
1396
1397 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
1399 self.auth = Some(HttpAuth::Bearer {
1400 token: token.into(),
1401 });
1402 self
1403 }
1404
1405 pub fn with_api_key(mut self, header: impl Into<String>, value: impl Into<String>) -> Self {
1407 self.headers.push((header.into(), value.into()));
1408 self
1409 }
1410}
1411
1412impl InventorySource for HttpInventorySource {
1413 fn load(&self) -> Result<Inventory, InventoryError> {
1414 let mut request = self.client.get(&self.url);
1415 for (key, value) in &self.headers {
1416 request = request.header(key, value);
1417 }
1418 if let Some(auth) = &self.auth {
1419 match auth {
1420 HttpAuth::Basic { username, password } => {
1421 request = request.basic_auth(username, Some(password));
1422 }
1423 HttpAuth::Bearer { token } => {
1424 request = request.bearer_auth(token);
1425 }
1426 }
1427 }
1428 let response = request.send()?.error_for_status()?;
1429 let body = response.text()?;
1430 parse_inventory_body(self.format, &body)
1431 }
1432
1433 fn name(&self) -> &str {
1434 &self.url
1435 }
1436}
1437
1438fn parse_inventory_body(format: InventoryFormat, body: &str) -> Result<Inventory, InventoryError> {
1440 match format {
1441 InventoryFormat::Yaml => {
1442 serde_yaml::from_str(body).map_err(|err| InventoryError::Parse(err.to_string()))
1443 }
1444 InventoryFormat::Json => {
1445 serde_json::from_str(body).map_err(|err| InventoryError::Parse(err.to_string()))
1446 }
1447 InventoryFormat::Toml => {
1448 toml::from_str(body).map_err(|err| InventoryError::Parse(err.to_string()))
1449 }
1450 }
1451}
1452
1453const NAUTOBOT_DEVICES_ENDPOINT: &str = "dcim/devices/";
1454const NAUTOBOT_VMS_ENDPOINT: &str = "virtualization/virtual-machines/";
1455const NAUTOBOT_DEFAULT_DEPTH: &str = "1";
1456const NETBOX_DEVICES_ENDPOINT: &str = "api/dcim/devices/";
1457const NETBOX_VMS_ENDPOINT: &str = "api/virtualization/virtual-machines/";
1458const NETBOX_PLATFORMS_ENDPOINT: &str = "api/dcim/platforms/";
1459
1460pub struct NautobotInventoryConfig {
1462 pub base_url: String,
1463 pub token: String,
1464 pub verify_tls: bool,
1465 pub timeout: Duration,
1466 pub include_virtual_machines: bool,
1467 pub flatten_custom_fields: bool,
1468 pub tags_as_groups: bool,
1469 pub sites_as_groups: bool,
1470 pub roles_as_groups: bool,
1471 pub tenants_as_groups: bool,
1472 pub page_size: u32,
1473 pub device_filters: Vec<(String, String)>,
1474 pub virtual_machine_filters: Vec<(String, String)>,
1475}
1476
1477pub struct NautobotInventorySource {
1479 client: reqwest::blocking::Client,
1480 base_url: Url,
1481 token: String,
1482 include_virtual_machines: bool,
1483 flatten_custom_fields: bool,
1484 tags_as_groups: bool,
1485 sites_as_groups: bool,
1486 roles_as_groups: bool,
1487 tenants_as_groups: bool,
1488 page_size: u32,
1489 device_filters: Vec<(String, String)>,
1490 vm_filters: Vec<(String, String)>,
1491}
1492
1493impl NautobotInventorySource {
1494 pub fn new(config: NautobotInventoryConfig) -> Result<Self, InventoryError> {
1496 let mut builder = reqwest::blocking::Client::builder()
1497 .timeout(config.timeout)
1498 .user_agent("kore/nautobot");
1499 if !config.verify_tls {
1500 builder = builder.danger_accept_invalid_certs(true);
1501 }
1502 let client = builder.build()?;
1503 let base_url = Url::parse(&config.base_url)
1504 .map_err(|err| InventoryError::Parse(format!("invalid Nautobot url: {err}")))?;
1505 Ok(Self {
1506 client,
1507 base_url,
1508 token: config.token,
1509 include_virtual_machines: config.include_virtual_machines,
1510 flatten_custom_fields: config.flatten_custom_fields,
1511 tags_as_groups: config.tags_as_groups,
1512 sites_as_groups: config.sites_as_groups,
1513 roles_as_groups: config.roles_as_groups,
1514 tenants_as_groups: config.tenants_as_groups,
1515 page_size: config.page_size.max(1),
1516 device_filters: config.device_filters,
1517 vm_filters: config.virtual_machine_filters,
1518 })
1519 }
1520
1521 fn fetch_collection(
1522 &self,
1523 endpoint: &str,
1524 filters: &[(String, String)],
1525 ) -> Result<Vec<serde_json::Value>, InventoryError> {
1526 let mut cursor = Some(self.base_url.join(endpoint).map_err(|err| {
1527 InventoryError::Parse(format!("failed to construct Nautobot URL: {err}"))
1528 })?);
1529 if let Some(current) = cursor.as_mut() {
1530 {
1531 let mut pairs = current.query_pairs_mut();
1532 pairs.append_pair("limit", &self.page_size.to_string());
1533 pairs.append_pair("offset", "0");
1534 pairs.append_pair("depth", NAUTOBOT_DEFAULT_DEPTH);
1535 for (key, value) in filters {
1536 pairs.append_pair(key, value);
1537 }
1538 }
1539 }
1540 let mut results = Vec::new();
1541 while let Some(current) = cursor.take() {
1542 let payload = self.get_page(current)?;
1543 results.extend(payload.results.into_iter());
1544 cursor = match payload.next {
1545 Some(next) => Some(self.parse_next_url(&next)?),
1546 None => None,
1547 };
1548 }
1549 Ok(results)
1550 }
1551
1552 fn get_page(&self, url: Url) -> Result<NautobotListResponse, InventoryError> {
1553 let response = self
1554 .client
1555 .get(url.clone())
1556 .header("Authorization", format!("Token {}", self.token))
1557 .header("Accept", "application/json")
1558 .send()?
1559 .error_for_status()?;
1560 Ok(response.json()?)
1561 }
1562
1563 fn parse_next_url(&self, raw: &str) -> Result<Url, InventoryError> {
1564 match Url::parse(raw) {
1565 Ok(url) => Ok(url),
1566 Err(_) => self.base_url.join(raw).map_err(|err| {
1567 InventoryError::Parse(format!("invalid Nautobot pagination URL: {err}"))
1568 }),
1569 }
1570 }
1571
1572 fn build_device_host(&self, record: &serde_json::Value) -> Result<Host, InventoryError> {
1573 self.build_host(record, NautobotObjectKind::Device)
1574 }
1575
1576 fn build_vm_host(&self, record: &serde_json::Value) -> Result<Host, InventoryError> {
1577 self.build_host(record, NautobotObjectKind::VirtualMachine)
1578 }
1579
1580 fn build_host(
1581 &self,
1582 record: &serde_json::Value,
1583 kind: NautobotObjectKind,
1584 ) -> Result<Host, InventoryError> {
1585 let name = self
1586 .string_field(record, "name")
1587 .or_else(|| self.string_field(record, "display"))
1588 .or_else(|| record.get("id").map(|id| id.to_string()))
1589 .ok_or_else(|| InventoryError::Parse("nautobot record missing name".into()))?;
1590 let hostname = self
1591 .primary_ip(record)
1592 .or_else(|| self.string_field(record, "name"))
1593 .unwrap_or_else(|| name.clone());
1594 let mut host = Host {
1595 name: name.clone(),
1596 hostname: Some(hostname),
1597 platform: self.platform_slug(record),
1598 ..Host::default()
1599 };
1600 host.groups = self.derive_groups(record, kind);
1601 host.data = self.build_data(record, kind);
1602 Ok(host)
1603 }
1604
1605 fn build_data(&self, record: &serde_json::Value, kind: NautobotObjectKind) -> Variables {
1606 let mut data = Variables::new();
1607 data.insert("nautobot".into(), record.clone());
1608 data.insert("kind".into(), serde_json::json!(kind.as_str()));
1609 if let Some(status) = self.nested_field(record, &["status", "value"]) {
1610 data.insert("status".into(), serde_json::json!(status));
1611 }
1612 if let Some(serial) = self.string_field(record, "serial") {
1613 data.insert("serial".into(), serde_json::json!(serial));
1614 }
1615 if let Some(asset) = self.string_field(record, "asset_tag") {
1616 data.insert("asset_tag".into(), serde_json::json!(asset));
1617 }
1618 if let Some(role) = self
1619 .nested_field(record, &["role", "slug"])
1620 .or_else(|| self.nested_field(record, &["device_role", "slug"]))
1621 {
1622 data.insert("role".into(), serde_json::json!(role));
1623 }
1624 if let Some(site) = self.nested_field(record, &["site", "slug"]) {
1625 data.insert("site".into(), serde_json::json!(site));
1626 }
1627 if let Some(cluster) = self.nested_field(record, &["cluster", "slug"]) {
1628 data.insert("cluster".into(), serde_json::json!(cluster));
1629 }
1630 if let Some(tenant) = self.nested_field(record, &["tenant", "slug"]) {
1631 data.insert("tenant".into(), serde_json::json!(tenant));
1632 }
1633 if let Some(ipv4) = self.primary_ip_field(record, "primary_ip4") {
1634 data.insert("primary_ip4".into(), serde_json::json!(ipv4));
1635 }
1636 if let Some(ipv6) = self.primary_ip_field(record, "primary_ip6") {
1637 data.insert("primary_ip6".into(), serde_json::json!(ipv6));
1638 }
1639 if let Some(primary) = self.primary_ip_field(record, "primary_ip") {
1640 data.insert("primary_ip".into(), serde_json::json!(primary));
1641 }
1642 if let Some(tags) = record
1643 .get("tags")
1644 .and_then(|value| value.as_array())
1645 .map(|list| {
1646 list.iter()
1647 .filter_map(Self::tag_slug)
1648 .map(|slug| serde_json::json!(slug))
1649 .collect::<Vec<_>>()
1650 })
1651 {
1652 data.insert("tags".into(), serde_json::Value::Array(tags));
1653 }
1654 if let Some(cf) = record.get("custom_fields") {
1655 if self.flatten_custom_fields {
1656 if let Some(map) = cf.as_object() {
1657 for (key, value) in map {
1658 data.insert(key.clone(), value.clone());
1659 }
1660 }
1661 } else {
1662 data.insert("custom_fields".into(), cf.clone());
1663 }
1664 }
1665 data
1666 }
1667
1668 fn derive_groups(&self, record: &serde_json::Value, kind: NautobotObjectKind) -> Vec<String> {
1669 let mut groups = Vec::new();
1670 self.push_group(&mut groups, Some(kind.as_str()), "kind:");
1671 if self.roles_as_groups
1672 && let Some(role) = self
1673 .nested_field(record, &["role", "slug"])
1674 .or_else(|| self.nested_field(record, &["device_role", "slug"]))
1675 {
1676 self.push_group(&mut groups, Some(&role), "role:");
1677 }
1678 if self.sites_as_groups
1679 && let Some(site) = self.nested_field(record, &["site", "slug"])
1680 {
1681 self.push_group(&mut groups, Some(&site), "site:");
1682 }
1683 if self.tenants_as_groups
1684 && let Some(tenant) = self.nested_field(record, &["tenant", "slug"])
1685 {
1686 self.push_group(&mut groups, Some(&tenant), "tenant:");
1687 }
1688 if let NautobotObjectKind::VirtualMachine = kind
1689 && let Some(cluster) = self.nested_field(record, &["cluster", "slug"])
1690 {
1691 self.push_group(&mut groups, Some(&cluster), "cluster:");
1692 }
1693 if self.tags_as_groups
1694 && let Some(tags) = record.get("tags").and_then(|value| value.as_array())
1695 {
1696 for tag in tags {
1697 if let Some(slug) = Self::tag_slug(tag) {
1698 self.push_group(&mut groups, Some(slug.as_str()), "tag:");
1699 }
1700 }
1701 }
1702 groups
1703 }
1704
1705 fn push_group(&self, groups: &mut Vec<String>, value: Option<&str>, prefix: &str) {
1706 if let Some(val) = value {
1707 let entry = format!("{prefix}{val}");
1708 if !groups.contains(&entry) {
1709 groups.push(entry);
1710 }
1711 }
1712 }
1713
1714 fn string_field(&self, record: &serde_json::Value, key: &str) -> Option<String> {
1715 record.get(key)?.as_str().map(|value| value.to_string())
1716 }
1717
1718 fn nested_field(&self, record: &serde_json::Value, path: &[&str]) -> Option<String> {
1719 let mut cursor = record;
1720 for segment in path.iter().take(path.len().saturating_sub(1)) {
1721 cursor = cursor.get(*segment)?;
1722 }
1723 cursor
1724 .get(*path.last()?)
1725 .and_then(|value| value.as_str().map(|s| s.to_string()))
1726 }
1727
1728 fn primary_ip_field(&self, record: &serde_json::Value, field: &str) -> Option<String> {
1729 let address = record.get(field)?.get("address")?.as_str()?;
1730 Some(address.split('/').next().unwrap_or(address).to_string())
1731 }
1732
1733 fn primary_ip(&self, record: &serde_json::Value) -> Option<String> {
1734 self.primary_ip_field(record, "primary_ip4")
1735 .or_else(|| self.primary_ip_field(record, "primary_ip"))
1736 .or_else(|| self.primary_ip_field(record, "primary_ip6"))
1737 }
1738
1739 fn platform_slug(&self, record: &serde_json::Value) -> Option<String> {
1740 self.nested_field(record, &["platform", "slug"])
1741 .or_else(|| self.nested_field(record, &["platform", "network_driver"]))
1742 }
1743
1744 fn tag_slug(value: &serde_json::Value) -> Option<String> {
1745 value
1746 .get("slug")
1747 .and_then(|field| field.as_str())
1748 .map(|slug| slug.to_string())
1749 .or_else(|| {
1750 value
1751 .get("name")
1752 .and_then(|field| field.as_str())
1753 .map(|name| name.to_string())
1754 })
1755 .or_else(|| {
1756 value
1757 .get("display")
1758 .and_then(|field| field.as_str())
1759 .map(|display| display.to_string())
1760 })
1761 .or_else(|| {
1762 value
1763 .get("natural_slug")
1764 .and_then(|field| field.as_str())
1765 .map(|slug| slug.to_string())
1766 })
1767 }
1768}
1769
1770impl InventorySource for NautobotInventorySource {
1771 fn load(&self) -> Result<Inventory, InventoryError> {
1772 let mut inventory = Inventory::default();
1773 for device in self.fetch_collection(NAUTOBOT_DEVICES_ENDPOINT, &self.device_filters)? {
1774 let host = self.build_device_host(&device)?;
1775 inventory.hosts.insert(host.name.clone(), host);
1776 }
1777 if self.include_virtual_machines {
1778 for vm in self.fetch_collection(NAUTOBOT_VMS_ENDPOINT, &self.vm_filters)? {
1779 let host = self.build_vm_host(&vm)?;
1780 inventory.hosts.insert(host.name.clone(), host);
1781 }
1782 }
1783 Ok(inventory)
1784 }
1785
1786 fn name(&self) -> &str {
1787 self.base_url.as_str()
1788 }
1789}
1790
1791#[derive(Deserialize)]
1792struct NautobotListResponse {
1793 next: Option<String>,
1794 results: Vec<serde_json::Value>,
1795}
1796
1797#[derive(Clone, Copy)]
1798enum NautobotObjectKind {
1799 Device,
1800 VirtualMachine,
1801}
1802
1803impl NautobotObjectKind {
1804 fn as_str(&self) -> &'static str {
1805 match self {
1806 NautobotObjectKind::Device => "device",
1807 NautobotObjectKind::VirtualMachine => "virtual_machine",
1808 }
1809 }
1810}
1811
1812pub struct NetboxInventoryConfig {
1814 pub base_url: String,
1815 pub token: String,
1816 pub verify_tls: bool,
1817 pub timeout: Duration,
1818 pub include_virtual_machines: bool,
1819 pub flatten_custom_fields: bool,
1820 pub tags_as_groups: bool,
1821 pub sites_as_groups: bool,
1822 pub roles_as_groups: bool,
1823 pub tenants_as_groups: bool,
1824 pub use_platform_slug: bool,
1825 pub use_platform_napalm_driver: bool,
1826 pub page_size: u32,
1827 pub device_filters: Vec<(String, String)>,
1828 pub virtual_machine_filters: Vec<(String, String)>,
1829 pub group_file: Option<PathBuf>,
1830 pub defaults_file: Option<PathBuf>,
1831}
1832
1833pub struct NetboxInventorySource {
1835 client: reqwest::blocking::Client,
1836 base_url: Url,
1837 auth_header: String,
1838 include_virtual_machines: bool,
1839 flatten_custom_fields: bool,
1840 tags_as_groups: bool,
1841 sites_as_groups: bool,
1842 roles_as_groups: bool,
1843 tenants_as_groups: bool,
1844 use_platform_slug: bool,
1845 use_platform_napalm_driver: bool,
1846 page_size: u32,
1847 device_filters: Vec<(String, String)>,
1848 vm_filters: Vec<(String, String)>,
1849 group_file: Option<PathBuf>,
1850 defaults_file: Option<PathBuf>,
1851}
1852
1853impl NetboxInventorySource {
1854 pub fn new(config: NetboxInventoryConfig) -> Result<Self, InventoryError> {
1856 let mut builder = reqwest::blocking::Client::builder()
1857 .timeout(config.timeout)
1858 .user_agent("kore/netbox");
1859 if !config.verify_tls {
1860 builder = builder.danger_accept_invalid_certs(true);
1861 }
1862 let client = builder.build()?;
1863 let base_url = Url::parse(&config.base_url)
1864 .map_err(|err| InventoryError::Parse(format!("invalid NetBox url: {err}")))?;
1865 Ok(Self {
1866 client,
1867 base_url,
1868 auth_header: Self::format_auth_header(&config.token),
1869 include_virtual_machines: config.include_virtual_machines,
1870 flatten_custom_fields: config.flatten_custom_fields,
1871 tags_as_groups: config.tags_as_groups,
1872 sites_as_groups: config.sites_as_groups,
1873 roles_as_groups: config.roles_as_groups,
1874 tenants_as_groups: config.tenants_as_groups,
1875 use_platform_slug: config.use_platform_slug,
1876 use_platform_napalm_driver: config.use_platform_napalm_driver,
1877 page_size: config.page_size.max(1),
1878 device_filters: config.device_filters,
1879 vm_filters: config.virtual_machine_filters,
1880 group_file: config.group_file,
1881 defaults_file: config.defaults_file,
1882 })
1883 }
1884
1885 fn fetch_collection(
1886 &self,
1887 endpoint: &str,
1888 filters: &[(String, String)],
1889 ) -> Result<Vec<serde_json::Value>, InventoryError> {
1890 let mut cursor = Some(self.base_url.join(endpoint).map_err(|err| {
1891 InventoryError::Parse(format!("failed to construct NetBox URL: {err}"))
1892 })?);
1893 if let Some(current) = cursor.as_mut() {
1894 {
1895 let mut pairs = current.query_pairs_mut();
1896 pairs.append_pair("limit", &self.page_size.to_string());
1897 pairs.append_pair("offset", "0");
1898 for (key, value) in filters {
1899 pairs.append_pair(key, value);
1900 }
1901 }
1902 }
1903 let mut results = Vec::new();
1904 while let Some(current) = cursor.take() {
1905 let payload = self.get_page(current)?;
1906 results.extend(payload.results.into_iter());
1907 cursor = match payload.next {
1908 Some(next) => Some(self.parse_next_url(&next)?),
1909 None => None,
1910 };
1911 }
1912 Ok(results)
1913 }
1914
1915 fn get_page(&self, url: Url) -> Result<NetboxListResponse, InventoryError> {
1916 let response = self
1917 .client
1918 .get(url.clone())
1919 .header("Authorization", self.auth_header.clone())
1920 .header("Accept", "application/json")
1921 .send()?
1922 .error_for_status()?;
1923 Ok(response.json()?)
1924 }
1925
1926 fn format_auth_header(token: &str) -> String {
1927 let trimmed = token.trim();
1928 if trimmed.starts_with("Bearer ") {
1929 trimmed.to_string()
1930 } else if trimmed.starts_with("nbt_") {
1931 format!("Bearer {trimmed}")
1932 } else {
1933 format!("Token {trimmed}")
1934 }
1935 }
1936
1937 fn parse_next_url(&self, raw: &str) -> Result<Url, InventoryError> {
1938 match Url::parse(raw) {
1939 Ok(url) => Ok(url),
1940 Err(_) => self.base_url.join(raw).map_err(|err| {
1941 InventoryError::Parse(format!("invalid NetBox pagination URL: {err}"))
1942 }),
1943 }
1944 }
1945
1946 fn fetch_platform_drivers(&self) -> Result<HashMap<String, String>, InventoryError> {
1947 let mut lookup = HashMap::new();
1948 for platform in self.fetch_collection(NETBOX_PLATFORMS_ENDPOINT, &[])? {
1949 if let (Some(slug), Some(driver)) = (
1950 platform.get("slug").and_then(|v| v.as_str()),
1951 platform.get("napalm_driver").and_then(|v| v.as_str()),
1952 ) {
1953 lookup.insert(slug.to_string(), driver.to_string());
1954 }
1955 }
1956 Ok(lookup)
1957 }
1958
1959 fn build_device_host(
1960 &self,
1961 record: &serde_json::Value,
1962 platform_lookup: Option<&HashMap<String, String>>,
1963 ) -> Result<Host, InventoryError> {
1964 self.build_host(record, NetboxObjectKind::Device, platform_lookup)
1965 }
1966
1967 fn build_vm_host(
1968 &self,
1969 record: &serde_json::Value,
1970 platform_lookup: Option<&HashMap<String, String>>,
1971 ) -> Result<Host, InventoryError> {
1972 self.build_host(record, NetboxObjectKind::VirtualMachine, platform_lookup)
1973 }
1974
1975 fn build_host(
1976 &self,
1977 record: &serde_json::Value,
1978 kind: NetboxObjectKind,
1979 platform_lookup: Option<&HashMap<String, String>>,
1980 ) -> Result<Host, InventoryError> {
1981 let name = self
1982 .string_field(record, "name")
1983 .or_else(|| self.string_field(record, "display"))
1984 .or_else(|| record.get("id").map(|id| id.to_string()))
1985 .ok_or_else(|| InventoryError::Parse("netbox record missing name".into()))?;
1986 let hostname = self
1987 .primary_ip(record)
1988 .or_else(|| self.string_field(record, "name"))
1989 .unwrap_or_else(|| name.clone());
1990 let mut host = Host {
1991 name: name.clone(),
1992 hostname: Some(hostname),
1993 platform: self.platform_value(record, platform_lookup),
1994 ..Host::default()
1995 };
1996 host.groups = self.derive_groups(record, kind);
1997 host.data = self.build_data(record, kind);
1998 Ok(host)
1999 }
2000
2001 fn build_data(&self, record: &serde_json::Value, kind: NetboxObjectKind) -> Variables {
2002 let mut data = Variables::new();
2003 data.insert("netbox".into(), record.clone());
2004 data.insert("kind".into(), serde_json::json!(kind.as_str()));
2005 if let Some(status) = self.nested_field(record, &["status", "value"]) {
2006 data.insert("status".into(), serde_json::json!(status));
2007 }
2008 if let Some(serial) = self.string_field(record, "serial") {
2009 data.insert("serial".into(), serde_json::json!(serial));
2010 }
2011 if let Some(asset) = self.string_field(record, "asset_tag") {
2012 data.insert("asset_tag".into(), serde_json::json!(asset));
2013 }
2014 if let Some(role) = self
2015 .nested_field(record, &["device_role", "slug"])
2016 .or_else(|| self.nested_field(record, &["role", "slug"]))
2017 {
2018 data.insert("role".into(), serde_json::json!(role));
2019 }
2020 if let Some(site) = self.nested_field(record, &["site", "slug"]) {
2021 data.insert("site".into(), serde_json::json!(site));
2022 }
2023 if let Some(tenant) = self.nested_field(record, &["tenant", "slug"]) {
2024 data.insert("tenant".into(), serde_json::json!(tenant));
2025 }
2026 if let Some(cluster) = self.nested_field(record, &["cluster", "slug"]) {
2027 data.insert("cluster".into(), serde_json::json!(cluster));
2028 }
2029 if let Some(primary) = self.primary_ip_field(record, "primary_ip") {
2030 data.insert("primary_ip".into(), serde_json::json!(primary));
2031 }
2032 if let Some(primary) = self.primary_ip_field(record, "primary_ip4") {
2033 data.insert("primary_ip4".into(), serde_json::json!(primary));
2034 }
2035 if let Some(primary) = self.primary_ip_field(record, "primary_ip6") {
2036 data.insert("primary_ip6".into(), serde_json::json!(primary));
2037 }
2038 if let Some(tags) = record
2039 .get("tags")
2040 .and_then(|value| value.as_array())
2041 .map(|list| {
2042 list.iter()
2043 .filter_map(Self::tag_slug)
2044 .map(serde_json::Value::String)
2045 .collect::<Vec<_>>()
2046 })
2047 {
2048 data.insert("tags".into(), serde_json::Value::Array(tags));
2049 }
2050 if let Some(cf) = record.get("custom_fields") {
2051 if self.flatten_custom_fields {
2052 if let Some(map) = cf.as_object() {
2053 for (key, value) in map {
2054 data.insert(key.clone(), value.clone());
2055 }
2056 }
2057 } else {
2058 data.insert("custom_fields".into(), cf.clone());
2059 }
2060 }
2061 data
2062 }
2063
2064 fn derive_groups(&self, record: &serde_json::Value, kind: NetboxObjectKind) -> Vec<String> {
2065 let mut groups = Vec::new();
2066 self.push_group(&mut groups, Some(kind.as_str()), "kind:");
2067 if self.roles_as_groups
2068 && let Some(role) = self
2069 .nested_field(record, &["device_role", "slug"])
2070 .or_else(|| self.nested_field(record, &["role", "slug"]))
2071 {
2072 self.push_group(&mut groups, Some(&role), "role:");
2073 }
2074 if self.sites_as_groups
2075 && let Some(site) = self.nested_field(record, &["site", "slug"])
2076 {
2077 self.push_group(&mut groups, Some(&site), "site:");
2078 }
2079 if self.tenants_as_groups
2080 && let Some(tenant) = self.nested_field(record, &["tenant", "slug"])
2081 {
2082 self.push_group(&mut groups, Some(&tenant), "tenant:");
2083 }
2084 if matches!(kind, NetboxObjectKind::VirtualMachine)
2085 && let Some(cluster) = self.nested_field(record, &["cluster", "slug"])
2086 {
2087 self.push_group(&mut groups, Some(&cluster), "cluster:");
2088 }
2089 if let Some(platform) = self.nested_field(record, &["platform", "slug"]) {
2090 self.push_group(&mut groups, Some(&platform), "platform:");
2091 }
2092 if let Some(device_type) = self.nested_field(record, &["device_type", "slug"]) {
2093 self.push_group(&mut groups, Some(&device_type), "device_type:");
2094 }
2095 if let Some(manufacturer) =
2096 self.nested_field(record, &["device_type", "manufacturer", "slug"])
2097 {
2098 self.push_group(&mut groups, Some(&manufacturer), "manufacturer:");
2099 }
2100 if self.tags_as_groups
2101 && let Some(tags) = record.get("tags").and_then(|value| value.as_array())
2102 {
2103 for tag in tags {
2104 if let Some(slug) = Self::tag_slug(tag) {
2105 self.push_group(&mut groups, Some(&slug), "tag:");
2106 }
2107 }
2108 }
2109 groups
2110 }
2111
2112 fn push_group(&self, groups: &mut Vec<String>, value: Option<&str>, prefix: &str) {
2113 if let Some(val) = value {
2114 let entry = format!("{prefix}{val}");
2115 if !groups.contains(&entry) {
2116 groups.push(entry);
2117 }
2118 }
2119 }
2120
2121 fn string_field(&self, record: &serde_json::Value, key: &str) -> Option<String> {
2122 record.get(key)?.as_str().map(|value| value.to_string())
2123 }
2124
2125 fn nested_field(&self, record: &serde_json::Value, path: &[&str]) -> Option<String> {
2126 let mut cursor = record;
2127 for segment in path.iter().take(path.len().saturating_sub(1)) {
2128 cursor = cursor.get(*segment)?;
2129 }
2130 cursor
2131 .get(*path.last()?)
2132 .and_then(|value| value.as_str().map(|s| s.to_string()))
2133 }
2134
2135 fn primary_ip_field(&self, record: &serde_json::Value, field: &str) -> Option<String> {
2136 let address = record.get(field)?.get("address")?.as_str()?;
2137 Some(address.split('/').next().unwrap_or(address).to_string())
2138 }
2139
2140 fn primary_ip(&self, record: &serde_json::Value) -> Option<String> {
2141 self.primary_ip_field(record, "primary_ip4")
2142 .or_else(|| self.primary_ip_field(record, "primary_ip"))
2143 .or_else(|| self.primary_ip_field(record, "primary_ip6"))
2144 }
2145
2146 fn platform_value(
2147 &self,
2148 record: &serde_json::Value,
2149 platform_lookup: Option<&HashMap<String, String>>,
2150 ) -> Option<String> {
2151 let platform = record.get("platform")?;
2152 match platform {
2153 serde_json::Value::String(value) => Some(value.clone()),
2154 serde_json::Value::Object(map) => {
2155 if self.use_platform_napalm_driver
2156 && let Some(slug) = map.get("slug").and_then(|v| v.as_str())
2157 && let Some(lookup) = platform_lookup
2158 && let Some(driver) = lookup.get(slug)
2159 {
2160 return Some(driver.clone());
2161 }
2162 if self.use_platform_slug {
2163 map.get("slug")
2164 .and_then(|value| value.as_str())
2165 .map(|s| s.to_string())
2166 .or_else(|| {
2167 map.get("name")
2168 .and_then(|value| value.as_str())
2169 .map(|s| s.to_string())
2170 })
2171 } else {
2172 map.get("name")
2173 .and_then(|value| value.as_str())
2174 .map(|s| s.to_string())
2175 }
2176 }
2177 _ => None,
2178 }
2179 }
2180
2181 fn tag_slug(value: &serde_json::Value) -> Option<String> {
2182 value
2183 .get("slug")
2184 .and_then(|field| field.as_str())
2185 .map(|slug| slug.to_string())
2186 .or_else(|| {
2187 value
2188 .get("name")
2189 .and_then(|field| field.as_str())
2190 .map(|name| name.to_string())
2191 })
2192 .or_else(|| {
2193 value
2194 .get("display")
2195 .and_then(|field| field.as_str())
2196 .map(|display| display.to_string())
2197 })
2198 .or_else(|| {
2199 value
2200 .get("natural_slug")
2201 .and_then(|field| field.as_str())
2202 .map(|slug| slug.to_string())
2203 })
2204 }
2205
2206 fn load_overlays(&self) -> Result<Option<Inventory>, InventoryError> {
2207 let mut overlays = CompositeFileInventorySource::new();
2208 let mut has_files = false;
2209 if let Some(path) = &self.group_file
2210 && path.exists()
2211 {
2212 overlays = overlays.groups(path.clone())?;
2213 has_files = true;
2214 }
2215 if let Some(path) = &self.defaults_file
2216 && path.exists()
2217 {
2218 overlays = overlays.defaults(path.clone())?;
2219 has_files = true;
2220 }
2221 if has_files {
2222 overlays.load().map(Some)
2223 } else {
2224 Ok(None)
2225 }
2226 }
2227}
2228
2229impl InventorySource for NetboxInventorySource {
2230 fn load(&self) -> Result<Inventory, InventoryError> {
2231 let platform_lookup = if self.use_platform_napalm_driver {
2232 Some(self.fetch_platform_drivers()?)
2233 } else {
2234 None
2235 };
2236 let mut inventory = Inventory::default();
2237 for device in self.fetch_collection(NETBOX_DEVICES_ENDPOINT, &self.device_filters)? {
2238 let host = self.build_device_host(&device, platform_lookup.as_ref())?;
2239 inventory.hosts.insert(host.name.clone(), host);
2240 }
2241 if self.include_virtual_machines {
2242 for vm in self.fetch_collection(NETBOX_VMS_ENDPOINT, &self.vm_filters)? {
2243 let host = self.build_vm_host(&vm, platform_lookup.as_ref())?;
2244 inventory.hosts.insert(host.name.clone(), host);
2245 }
2246 }
2247 if let Some(overlays) = self.load_overlays()? {
2248 inventory.merge(overlays);
2249 }
2250 Ok(inventory)
2251 }
2252
2253 fn name(&self) -> &str {
2254 self.base_url.as_str()
2255 }
2256}
2257
2258#[derive(Deserialize)]
2259struct NetboxListResponse {
2260 next: Option<String>,
2261 results: Vec<serde_json::Value>,
2262}
2263
2264#[derive(Clone, Copy, PartialEq, Eq)]
2265enum NetboxObjectKind {
2266 Device,
2267 VirtualMachine,
2268}
2269
2270impl NetboxObjectKind {
2271 fn as_str(&self) -> &'static str {
2272 match self {
2273 NetboxObjectKind::Device => "device",
2274 NetboxObjectKind::VirtualMachine => "virtual_machine",
2275 }
2276 }
2277}
2278
2279#[cfg(test)]
2280mod tests {
2281 use super::*;
2282 use serde_json::json;
2283 use std::{fs, io::Write, path::Path, time::Duration};
2284 use tempfile::{NamedTempFile, tempdir};
2285
2286 #[test]
2287 fn merges_host_data() {
2288 let mut base = Inventory::default();
2289 base.hosts.insert(
2290 "r1".into(),
2291 Host {
2292 name: "r1".into(),
2293 hostname: Some("10.0.0.1".into()),
2294 port: Some(22),
2295 ..Host::default()
2296 },
2297 );
2298
2299 let mut override_inv = Inventory::default();
2300 override_inv.hosts.insert(
2301 "r1".into(),
2302 Host {
2303 name: "r1".into(),
2304 platform: Some("ios".into()),
2305 port: Some(2222),
2306 ..Host::default()
2307 },
2308 );
2309
2310 base.merge(override_inv);
2311
2312 let host = base.host("r1").unwrap();
2313 assert_eq!(host.hostname.as_deref(), Some("10.0.0.1"));
2314 assert_eq!(host.platform.as_deref(), Some("ios"));
2315 assert_eq!(host.port, Some(2222));
2316 }
2317
2318 #[test]
2319 fn merges_group_and_default_credentials() {
2320 let mut inventory = Inventory::default();
2321 inventory.groups.insert(
2322 "core".into(),
2323 Group {
2324 name: "core".into(),
2325 credentials: Some(Credentials {
2326 username: Some("group-user".into()),
2327 password: Some(Secret::new("group-pass")),
2328 ..Credentials::default()
2329 }),
2330 ..Group::default()
2331 },
2332 );
2333
2334 let mut override_inv = Inventory::default();
2335 override_inv.defaults.credentials = Some(Credentials {
2336 username: Some("default-user".into()),
2337 password: Some(Secret::new("default-pass")),
2338 private_key: Some(Secret::new("key")),
2339 enable_secret: None,
2340 });
2341 override_inv.groups.insert(
2342 "core".into(),
2343 Group {
2344 name: "core".into(),
2345 credentials: Some(Credentials {
2346 username: Some("override-user".into()),
2347 ..Credentials::default()
2348 }),
2349 ..Group::default()
2350 },
2351 );
2352
2353 inventory.merge(override_inv);
2354
2355 let group = inventory.groups.get("core").unwrap();
2356 assert_eq!(
2357 group.credentials.as_ref().unwrap().username.as_deref(),
2358 Some("override-user")
2359 );
2360 assert_eq!(
2361 group
2362 .credentials
2363 .as_ref()
2364 .unwrap()
2365 .password
2366 .as_ref()
2367 .unwrap()
2368 .as_str(),
2369 Some("group-pass")
2370 );
2371 assert_eq!(
2372 inventory
2373 .defaults
2374 .credentials
2375 .as_ref()
2376 .unwrap()
2377 .private_key
2378 .as_ref()
2379 .unwrap()
2380 .as_str(),
2381 Some("key")
2382 );
2383 }
2384
2385 #[test]
2386 fn merges_connection_parameters() {
2387 let mut inventory = Inventory::default();
2388 inventory.defaults.connections.insert(
2389 "ssh".into(),
2390 TransportSettings {
2391 transport: "ssh".into(),
2392 params: [("port".into(), json!(22))].into_iter().collect(),
2393 },
2394 );
2395
2396 let mut override_inv = Inventory::default();
2397 override_inv.defaults.connections.insert(
2398 "ssh".into(),
2399 TransportSettings {
2400 transport: "".into(),
2401 params: [("timeout".into(), json!(30))].into_iter().collect(),
2402 },
2403 );
2404
2405 inventory.merge(override_inv);
2406
2407 let conn = inventory.defaults.connections.get("ssh").unwrap();
2408 assert_eq!(conn.transport, "ssh");
2409 assert_eq!(conn.params.get("port").unwrap(), &json!(22));
2410 assert_eq!(conn.params.get("timeout").unwrap(), &json!(30));
2411 }
2412
2413 #[test]
2414 fn transport_settings_params_to_struct() {
2415 #[derive(Deserialize, PartialEq, Debug)]
2416 struct Config {
2417 username: String,
2418 retries: Option<u8>,
2419 }
2420
2421 let mut params = Variables::default();
2422 params.insert("username".into(), json!("automation"));
2423 params.insert("retries".into(), json!(3));
2424
2425 let settings = TransportSettings {
2426 transport: "ssh".into(),
2427 params,
2428 };
2429
2430 let config: Config = settings.params_as().unwrap();
2431 assert_eq!(
2432 config,
2433 Config {
2434 username: "automation".into(),
2435 retries: Some(3)
2436 }
2437 );
2438 }
2439
2440 #[test]
2441 fn builder_combines_sources() {
2442 let mut first = Inventory::default();
2443 first.hosts.insert(
2444 "r1".into(),
2445 Host {
2446 name: "r1".into(),
2447 hostname: Some("10.0.0.1".into()),
2448 ..Host::default()
2449 },
2450 );
2451
2452 let mut second = Inventory::default();
2453 second.hosts.insert(
2454 "r2".into(),
2455 Host {
2456 name: "r2".into(),
2457 hostname: Some("10.0.0.2".into()),
2458 ..Host::default()
2459 },
2460 );
2461
2462 let built = InventoryBuilder::new()
2463 .with_source(StaticInventorySource::new("first", first))
2464 .with_source(StaticInventorySource::new("second", second))
2465 .build()
2466 .unwrap();
2467
2468 assert!(built.host("r1").is_some());
2469 assert!(built.host("r2").is_some());
2470 }
2471
2472 #[test]
2473 fn host_inherits_group_and_defaults_data() {
2474 let mut hosts_inv = Inventory::default();
2475 hosts_inv.hosts.insert(
2476 "r1".into(),
2477 Host {
2478 name: "r1".into(),
2479 groups: vec!["core".into()],
2480 ..Host::default()
2481 },
2482 );
2483
2484 let mut groups_inv = Inventory::default();
2485 groups_inv.groups.insert(
2486 "core".into(),
2487 Group {
2488 name: "core".into(),
2489 data: {
2490 let mut data = Variables::default();
2491 data.insert("tier".into(), json!("core"));
2492 data
2493 },
2494 ..Group::default()
2495 },
2496 );
2497
2498 let mut defaults_inv = Inventory::default();
2499 defaults_inv
2500 .defaults
2501 .data
2502 .insert("owner".into(), json!("netops"));
2503
2504 let built = InventoryBuilder::new()
2505 .with_source(StaticInventorySource::new("hosts", hosts_inv))
2506 .with_source(StaticInventorySource::new("groups", groups_inv))
2507 .with_source(StaticInventorySource::new("defaults", defaults_inv))
2508 .build()
2509 .unwrap();
2510
2511 let host = built.host("r1").expect("host");
2512 assert_eq!(host.data.get("tier").unwrap(), "core");
2513 assert_eq!(host.data.get("owner").unwrap(), "netops");
2514 }
2515
2516 #[test]
2517 fn io_errors_note_path() {
2518 let err = io_error_with_path(
2519 std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
2520 Path::new("/tmp/demo.yaml"),
2521 );
2522 assert!(
2523 err.to_string().contains("/tmp/demo.yaml"),
2524 "io error should mention path: {}",
2525 err
2526 );
2527 }
2528
2529 #[test]
2530 fn file_loader_reads_yaml() {
2531 let mut file = NamedTempFile::new().unwrap();
2532 let yaml = r#"
2533hosts:
2534 r1:
2535 name: r1
2536 hostname: 10.0.0.5
2537defaults:
2538 data:
2539 env: lab
2540"#;
2541 file.write_all(yaml.as_bytes()).unwrap();
2542
2543 let source = FileInventorySource::new(file.path(), InventoryFormat::Yaml);
2544 let inventory = source.load().unwrap();
2545
2546 assert_eq!(
2547 inventory.host("r1").unwrap().hostname.as_deref(),
2548 Some("10.0.0.5")
2549 );
2550 assert_eq!(inventory.defaults.data.get("env").unwrap(), "lab");
2551 }
2552
2553 #[test]
2554 fn file_loader_reads_sequence_hosts() {
2555 let mut file = NamedTempFile::new().unwrap();
2556 let yaml = r#"
2557hosts:
2558 - name: core-rt
2559 hostname: 10.0.0.6
2560 port: 2222
2561"#;
2562 file.write_all(yaml.as_bytes()).unwrap();
2563
2564 let source = FileInventorySource::new(file.path(), InventoryFormat::Yaml);
2565 let inventory = source.load().unwrap();
2566 let host = inventory.host("core-rt").unwrap();
2567 assert_eq!(host.hostname.as_deref(), Some("10.0.0.6"));
2568 assert_eq!(host.port, Some(2222));
2569 }
2570
2571 #[test]
2572 fn composite_file_source_merges_parts() {
2573 let dir = tempdir().unwrap();
2574 let hosts_path = dir.path().join("hosts.yaml");
2575 let groups_path = dir.path().join("groups.yaml");
2576 let defaults_path = dir.path().join("defaults.yaml");
2577
2578 fs::write(
2579 &hosts_path,
2580 r#"
2581r1:
2582 hostname: 10.0.0.8
2583 groups: ["core"]
2584"#,
2585 )
2586 .unwrap();
2587
2588 fs::write(
2589 &groups_path,
2590 r#"
2591core:
2592 data:
2593 role: spine
2594"#,
2595 )
2596 .unwrap();
2597
2598 fs::write(
2599 &defaults_path,
2600 r#"
2601data:
2602 owner: neteng
2603"#,
2604 )
2605 .unwrap();
2606
2607 let source = CompositeFileInventorySource::new()
2608 .hosts(&hosts_path)
2609 .unwrap()
2610 .groups(&groups_path)
2611 .unwrap()
2612 .defaults(&defaults_path)
2613 .unwrap();
2614
2615 let inventory = source.load().unwrap();
2616
2617 assert_eq!(
2618 inventory.host("r1").unwrap().hostname.as_deref(),
2619 Some("10.0.0.8")
2620 );
2621 assert_eq!(
2622 inventory
2623 .groups
2624 .get("core")
2625 .unwrap()
2626 .data
2627 .get("role")
2628 .unwrap(),
2629 "spine"
2630 );
2631 assert_eq!(inventory.defaults.data.get("owner").unwrap(), "neteng");
2632 }
2633
2634 #[test]
2635 fn http_source_parses_payload() {
2636 let inventory = parse_inventory_body(
2637 InventoryFormat::Json,
2638 r#"{
2639 "hosts": {
2640 "r1": {
2641 "name": "r1",
2642 "hostname": "10.0.0.9"
2643 }
2644 }
2645 }"#,
2646 )
2647 .unwrap();
2648
2649 assert_eq!(
2650 inventory.host("r1").unwrap().hostname.as_deref(),
2651 Some("10.0.0.9")
2652 );
2653 }
2654
2655 #[test]
2656 fn inventory_format_parses_labels() {
2657 assert!(matches!(
2658 InventoryFormat::from_label("YAML"),
2659 Some(InventoryFormat::Yaml)
2660 ));
2661 assert!(matches!(
2662 InventoryFormat::from_label("json"),
2663 Some(InventoryFormat::Json)
2664 ));
2665 assert!(InventoryFormat::from_label("bogus").is_none());
2666 }
2667
2668 #[test]
2669 fn secret_deserializes_from_string() {
2670 #[derive(Deserialize)]
2671 struct Wrapper {
2672 password: Secret,
2673 }
2674 let parsed: Wrapper = serde_yaml::from_str("password: hunter2").unwrap();
2675 assert_eq!(parsed.password.as_str(), Some("hunter2"));
2676 assert!(parsed.password.reference.is_none());
2677 }
2678
2679 #[test]
2680 fn secret_supports_reference_fields() {
2681 let yaml = r#"
2682password:
2683 ref: netops/password
2684 provider: vault
2685 optional: true
2686"#;
2687 #[derive(Deserialize)]
2688 struct Wrapper {
2689 password: Secret,
2690 }
2691 let parsed: Wrapper = serde_yaml::from_str(yaml).unwrap();
2692 let reference = parsed.password.reference.as_ref().unwrap();
2693 assert_eq!(reference.key, "netops/password");
2694 assert_eq!(reference.provider.as_deref(), Some("vault"));
2695 assert!(reference.optional);
2696 assert!(parsed.password.as_str().is_none());
2697 }
2698
2699 #[test]
2700 fn nautobot_device_host_merges_expected_fields() {
2701 let source = NautobotInventorySource::new(NautobotInventoryConfig {
2702 base_url: "https://example/api/".into(),
2703 token: "abc123".into(),
2704 verify_tls: true,
2705 timeout: Duration::from_secs(1),
2706 include_virtual_machines: true,
2707 flatten_custom_fields: true,
2708 tags_as_groups: true,
2709 sites_as_groups: true,
2710 roles_as_groups: true,
2711 tenants_as_groups: true,
2712 page_size: 50,
2713 device_filters: Vec::new(),
2714 virtual_machine_filters: Vec::new(),
2715 })
2716 .unwrap();
2717 let record = serde_json::json!({
2718 "id": 1,
2719 "name": "core1-rt",
2720 "primary_ip4": { "address": "10.0.0.1/32" },
2721 "platform": { "slug": "iosxe" },
2722 "device_role": { "slug": "core" },
2723 "site": { "slug": "lab" },
2724 "tenant": { "slug": "test" },
2725 "status": { "value": "active" },
2726 "tags": [{ "slug": "edge" }],
2727 "custom_fields": { "region": "us-east" }
2728 });
2729 let host = source.build_device_host(&record).unwrap();
2730 assert_eq!(host.name, "core1-rt");
2731 assert_eq!(host.hostname.as_deref(), Some("10.0.0.1"));
2732 assert_eq!(host.platform.as_deref(), Some("iosxe"));
2733 assert!(host.groups.contains(&"role:core".into()));
2734 assert!(host.groups.contains(&"site:lab".into()));
2735 assert!(host.groups.contains(&"tag:edge".into()));
2736 assert!(host.groups.contains(&"tenant:test".into()));
2737 assert_eq!(
2738 host.data.get("region").and_then(|v| v.as_str()),
2739 Some("us-east")
2740 );
2741 assert_eq!(
2742 host.data
2743 .get("nautobot")
2744 .and_then(|v| v.get("name"))
2745 .and_then(|v| v.as_str()),
2746 Some("core1-rt")
2747 );
2748 }
2749
2750 #[test]
2751 fn nautobot_vm_host_sets_cluster_groups_and_primary_ip() {
2752 let source = NautobotInventorySource::new(NautobotInventoryConfig {
2753 base_url: "https://example/api/".into(),
2754 token: "xyz".into(),
2755 verify_tls: true,
2756 timeout: Duration::from_secs(1),
2757 include_virtual_machines: true,
2758 flatten_custom_fields: true,
2759 tags_as_groups: false,
2760 sites_as_groups: true,
2761 roles_as_groups: true,
2762 tenants_as_groups: true,
2763 page_size: 50,
2764 device_filters: Vec::new(),
2765 virtual_machine_filters: Vec::new(),
2766 })
2767 .unwrap();
2768 let record = serde_json::json!({
2769 "id": 2,
2770 "name": "dc1-vm1",
2771 "primary_ip": { "address": "192.0.2.10/32" },
2772 "platform": { "slug": "linux" },
2773 "role": { "slug": "app" },
2774 "cluster": { "slug": "compute" },
2775 "tenant": { "slug": "tenant1" },
2776 "status": { "value": "active" },
2777 "custom_fields": { "tier": "app" }
2778 });
2779 let host = source.build_vm_host(&record).unwrap();
2780 assert_eq!(host.hostname.as_deref(), Some("192.0.2.10"));
2781 assert!(host.groups.contains(&"role:app".into()));
2782 assert!(host.groups.contains(&"cluster:compute".into()));
2783 assert!(host.groups.contains(&"kind:virtual_machine".into()));
2784 assert_eq!(host.data.get("tier").and_then(|v| v.as_str()), Some("app"));
2785 }
2786
2787 #[test]
2788 fn netbox_device_host_sets_expected_fields() {
2789 let source = NetboxInventorySource::new(NetboxInventoryConfig {
2790 base_url: "https://netbox/api/".into(),
2791 token: "abc123".into(),
2792 verify_tls: true,
2793 timeout: Duration::from_secs(1),
2794 include_virtual_machines: true,
2795 flatten_custom_fields: true,
2796 tags_as_groups: true,
2797 sites_as_groups: true,
2798 roles_as_groups: true,
2799 tenants_as_groups: true,
2800 use_platform_slug: true,
2801 use_platform_napalm_driver: false,
2802 page_size: 50,
2803 device_filters: Vec::new(),
2804 virtual_machine_filters: Vec::new(),
2805 group_file: None,
2806 defaults_file: None,
2807 })
2808 .unwrap();
2809 let record = serde_json::json!({
2810 "id": 101,
2811 "name": "dc1-edge-1",
2812 "primary_ip": { "address": "10.10.10.10/32" },
2813 "platform": { "slug": "iosxe", "name": "IOS-XE" },
2814 "device_role": { "slug": "edge" },
2815 "site": { "slug": "lab" },
2816 "tenant": { "slug": "prod" },
2817 "device_type": {
2818 "slug": "c9300",
2819 "manufacturer": { "slug": "cisco" }
2820 },
2821 "tags": [{ "slug": "core" }],
2822 "custom_fields": { "region": "us-east" }
2823 });
2824 let host = source.build_device_host(&record, None).unwrap();
2825 assert_eq!(host.name, "dc1-edge-1");
2826 assert_eq!(host.hostname.as_deref(), Some("10.10.10.10"));
2827 assert!(host.groups.contains(&"site:lab".into()));
2828 assert!(host.groups.contains(&"role:edge".into()));
2829 assert!(host.groups.contains(&"manufacturer:cisco".into()));
2830 assert!(host.groups.contains(&"tag:core".into()));
2831 assert_eq!(
2832 host.data.get("region").and_then(|value| value.as_str()),
2833 Some("us-east")
2834 );
2835 assert_eq!(
2836 host.data
2837 .get("netbox")
2838 .and_then(|value| value.get("name"))
2839 .and_then(|value| value.as_str()),
2840 Some("dc1-edge-1")
2841 );
2842 }
2843
2844 #[test]
2845 fn netbox_vm_host_sets_cluster_group() {
2846 let source = NetboxInventorySource::new(NetboxInventoryConfig {
2847 base_url: "https://netbox/api/".into(),
2848 token: "xyz".into(),
2849 verify_tls: true,
2850 timeout: Duration::from_secs(1),
2851 include_virtual_machines: true,
2852 flatten_custom_fields: false,
2853 tags_as_groups: false,
2854 sites_as_groups: true,
2855 roles_as_groups: true,
2856 tenants_as_groups: false,
2857 use_platform_slug: true,
2858 use_platform_napalm_driver: false,
2859 page_size: 50,
2860 device_filters: Vec::new(),
2861 virtual_machine_filters: Vec::new(),
2862 group_file: None,
2863 defaults_file: None,
2864 })
2865 .unwrap();
2866 let record = serde_json::json!({
2867 "id": 202,
2868 "name": "app-vm1",
2869 "primary_ip4": { "address": "192.0.2.10/32" },
2870 "role": { "slug": "app" },
2871 "cluster": { "slug": "compute" },
2872 "custom_fields": {}
2873 });
2874 let host = source.build_vm_host(&record, None).unwrap();
2875 assert_eq!(host.hostname.as_deref(), Some("192.0.2.10"));
2876 assert!(host.groups.contains(&"role:app".into()));
2877 assert!(host.groups.contains(&"cluster:compute".into()));
2878 assert!(host.groups.contains(&"kind:virtual_machine".into()));
2879 }
2880
2881 #[test]
2882 fn netbox_authorization_header_supports_bearer_tokens() {
2883 assert_eq!(
2884 NetboxInventorySource::format_auth_header("foo"),
2885 "Token foo"
2886 );
2887 assert_eq!(
2888 NetboxInventorySource::format_auth_header("nbt_abc123"),
2889 "Bearer nbt_abc123"
2890 );
2891 assert_eq!(
2892 NetboxInventorySource::format_auth_header("Bearer nbt_token"),
2893 "Bearer nbt_token"
2894 );
2895 }
2896}