feat: Inventory PhysicalHost persistence with sqlx and local sqlite db #125
							
								
								
									
										3
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -2366,9 +2366,12 @@ version = "0.1.0" | |||||||
| dependencies = [ | dependencies = [ | ||||||
|  "actix-web", |  "actix-web", | ||||||
|  "env_logger", |  "env_logger", | ||||||
|  |  "harmony_macros", | ||||||
|  |  "harmony_types", | ||||||
|  "local-ip-address", |  "local-ip-address", | ||||||
|  "log", |  "log", | ||||||
|  "mdns-sd 0.14.1 (git+https://github.com/jggc/mdns-sd.git?branch=patch-1)", |  "mdns-sd 0.14.1 (git+https://github.com/jggc/mdns-sd.git?branch=patch-1)", | ||||||
|  |  "reqwest 0.12.20", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_json", |  "serde_json", | ||||||
|  "sysinfo", |  "sysinfo", | ||||||
|  | |||||||
| @ -67,4 +67,4 @@ serde = { version = "1.0.209", features = ["derive", "rc"] } | |||||||
| serde_json = "1.0.127" | serde_json = "1.0.127" | ||||||
| askama = "0.14" | askama = "0.14" | ||||||
| sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite" ] } | sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite" ] } | ||||||
| reqwest = { version = "0.12", features = ["stream", "rustls-tls", "http2"], default-features = false } | reqwest = { version = "0.12", features = ["blocking", "stream", "rustls-tls", "http2", "json"], default-features = false } | ||||||
|  | |||||||
							
								
								
									
										
											BIN
										
									
								
								data/pxe/okd/http_files/harmony_inventory_agent
									 (Stored with Git LFS)
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								data/pxe/okd/http_files/harmony_inventory_agent
									 (Stored with Git LFS)
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| @ -87,8 +87,7 @@ async fn main() { | |||||||
|     let inventory = Inventory { |     let inventory = Inventory { | ||||||
|         location: Location::new("I am mobile".to_string(), "earth".to_string()), |         location: Location::new("I am mobile".to_string(), "earth".to_string()), | ||||||
|         switch: SwitchGroup::from([]), |         switch: SwitchGroup::from([]), | ||||||
|         firewall: FirewallGroup::from([PhysicalHost::empty(HostCategory::Firewall) |         firewall_mgmt: Box::new(OPNSenseManagementInterface::new()), | ||||||
|             .management(Arc::new(OPNSenseManagementInterface::new()))]), |  | ||||||
|         storage_host: vec![], |         storage_host: vec![], | ||||||
|         worker_host: vec![ |         worker_host: vec![ | ||||||
|             PhysicalHost::empty(HostCategory::Server) |             PhysicalHost::empty(HostCategory::Server) | ||||||
|  | |||||||
| @ -69,8 +69,7 @@ pub fn get_inventory() -> Inventory { | |||||||
|             "testopnsense".to_string(), |             "testopnsense".to_string(), | ||||||
|         ), |         ), | ||||||
|         switch: SwitchGroup::from([]), |         switch: SwitchGroup::from([]), | ||||||
|         firewall: FirewallGroup::from([PhysicalHost::empty(HostCategory::Firewall) |         firewall_mgmt: Box::new(OPNSenseManagementInterface::new()), | ||||||
|             .management(Arc::new(OPNSenseManagementInterface::new()))]), |  | ||||||
|         storage_host: vec![], |         storage_host: vec![], | ||||||
|         worker_host: vec![], |         worker_host: vec![], | ||||||
|         control_plane_host: vec![], |         control_plane_host: vec![], | ||||||
|  | |||||||
| @ -63,8 +63,7 @@ async fn main() { | |||||||
|             "wk".to_string(), |             "wk".to_string(), | ||||||
|         ), |         ), | ||||||
|         switch: SwitchGroup::from([]), |         switch: SwitchGroup::from([]), | ||||||
|         firewall: FirewallGroup::from([PhysicalHost::empty(HostCategory::Firewall) |         firewall_mgmt: Box::new(OPNSenseManagementInterface::new()), | ||||||
|             .management(Arc::new(OPNSenseManagementInterface::new()))]), |  | ||||||
|         storage_host: vec![], |         storage_host: vec![], | ||||||
|         worker_host: vec![], |         worker_host: vec![], | ||||||
|         control_plane_host: vec![ |         control_plane_host: vec![ | ||||||
|  | |||||||
| @ -12,4 +12,12 @@ lazy_static! { | |||||||
|         std::env::var("HARMONY_REGISTRY_PROJECT").unwrap_or_else(|_| "harmony".to_string()); |         std::env::var("HARMONY_REGISTRY_PROJECT").unwrap_or_else(|_| "harmony".to_string()); | ||||||
|     pub static ref DRY_RUN: bool = |     pub static ref DRY_RUN: bool = | ||||||
|         std::env::var("HARMONY_DRY_RUN").is_ok_and(|value| value.parse().unwrap_or(false)); |         std::env::var("HARMONY_DRY_RUN").is_ok_and(|value| value.parse().unwrap_or(false)); | ||||||
|  |     pub static ref DEFAULT_DATABASE_URL: String = "sqlite://harmony.sqlite".to_string(); | ||||||
|  |     pub static ref DATABASE_URL: String = std::env::var("HARMONY_DATABASE_URL") | ||||||
|  |         .map(|value| if value.is_empty() { | ||||||
|  |             (*DEFAULT_DATABASE_URL).clone() | ||||||
|  |         } else { | ||||||
|  |             value | ||||||
|  |         }) | ||||||
|  |         .unwrap_or((*DEFAULT_DATABASE_URL).clone()); | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,24 +1,24 @@ | |||||||
| use std::{str::FromStr, sync::Arc}; | use std::sync::Arc; | ||||||
| 
 | 
 | ||||||
| use derive_new::new; | use derive_new::new; | ||||||
|  | use harmony_inventory_agent::hwinfo::{CPU, MemoryModule, NetworkInterface, StorageDrive}; | ||||||
| use harmony_types::net::MacAddress; | use harmony_types::net::MacAddress; | ||||||
| use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct}; | use serde::{Deserialize, Serialize}; | ||||||
| use serde_value::Value; | use serde_value::Value; | ||||||
| 
 | 
 | ||||||
| pub type HostGroup = Vec<PhysicalHost>; | pub type HostGroup = Vec<PhysicalHost>; | ||||||
| pub type SwitchGroup = Vec<Switch>; | pub type SwitchGroup = Vec<Switch>; | ||||||
| pub type FirewallGroup = Vec<PhysicalHost>; | pub type FirewallGroup = Vec<PhysicalHost>; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Clone)] | #[derive(Debug, Clone, Serialize)] | ||||||
| pub struct PhysicalHost { | pub struct PhysicalHost { | ||||||
|     pub id: Id, |     pub id: Id, | ||||||
|     pub category: HostCategory, |     pub category: HostCategory, | ||||||
|     pub network: Vec<NetworkInterface>, |     pub network: Vec<NetworkInterface>, | ||||||
|     pub management: Arc<dyn ManagementInterface>, |     pub storage: Vec<StorageDrive>, | ||||||
|     pub storage: Vec<Storage>, |  | ||||||
|     pub labels: Vec<Label>, |     pub labels: Vec<Label>, | ||||||
|     pub memory_size: Option<u64>, |     pub memory_modules: Vec<MemoryModule>, | ||||||
|     pub cpu_count: Option<u64>, |     pub cpus: Vec<CPU>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl PhysicalHost { | impl PhysicalHost { | ||||||
| @ -29,12 +29,128 @@ impl PhysicalHost { | |||||||
|             network: vec![], |             network: vec![], | ||||||
|             storage: vec![], |             storage: vec![], | ||||||
|             labels: vec![], |             labels: vec![], | ||||||
|             management: Arc::new(ManualManagementInterface {}), |             memory_modules: vec![], | ||||||
|             memory_size: None, |             cpus: vec![], | ||||||
|             cpu_count: None, |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     pub fn summary(&self) -> String { | ||||||
|  |         let mut parts = Vec::new(); | ||||||
|  | 
 | ||||||
|  |         // Part 1: System Model (from labels) or Category as a fallback
 | ||||||
|  |         let model = self | ||||||
|  |             .labels | ||||||
|  |             .iter() | ||||||
|  |             .find(|l| l.name == "system-product-name" || l.name == "model") | ||||||
|  |             .map(|l| l.value.clone()) | ||||||
|  |             .unwrap_or_else(|| self.category.to_string()); | ||||||
|  |         parts.push(model); | ||||||
|  | 
 | ||||||
|  |         // Part 2: CPU Information
 | ||||||
|  |         if !self.cpus.is_empty() { | ||||||
|  |             let cpu_count = self.cpus.len(); | ||||||
|  |             let total_cores = self.cpus.iter().map(|c| c.cores).sum::<u32>(); | ||||||
|  |             let total_threads = self.cpus.iter().map(|c| c.threads).sum::<u32>(); | ||||||
|  |             let model_name = &self.cpus[0].model; | ||||||
|  | 
 | ||||||
|  |             let cpu_summary = if cpu_count > 1 { | ||||||
|  |                 format!( | ||||||
|  |                     "{}x {} ({}c/{}t)", | ||||||
|  |                     cpu_count, model_name, total_cores, total_threads | ||||||
|  |                 ) | ||||||
|  |             } else { | ||||||
|  |                 format!("{} ({}c/{}t)", model_name, total_cores, total_threads) | ||||||
|  |             }; | ||||||
|  |             parts.push(cpu_summary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Part 3: Memory Information
 | ||||||
|  |         if !self.memory_modules.is_empty() { | ||||||
|  |             let total_mem_bytes = self | ||||||
|  |                 .memory_modules | ||||||
|  |                 .iter() | ||||||
|  |                 .map(|m| m.size_bytes) | ||||||
|  |                 .sum::<u64>(); | ||||||
|  |             let total_mem_gb = (total_mem_bytes as f64 / (1024.0 * 1024.0 * 1024.0)).round() as u64; | ||||||
|  | 
 | ||||||
|  |             // Find the most common speed among modules
 | ||||||
|  |             let mut speeds = std::collections::HashMap::new(); | ||||||
|  |             for module in &self.memory_modules { | ||||||
|  |                 if let Some(speed) = module.speed_mhz { | ||||||
|  |                     *speeds.entry(speed).or_insert(0) += 1; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             let common_speed = speeds | ||||||
|  |                 .into_iter() | ||||||
|  |                 .max_by_key(|&(_, count)| count) | ||||||
|  |                 .map(|(speed, _)| speed); | ||||||
|  | 
 | ||||||
|  |             if let Some(speed) = common_speed { | ||||||
|  |                 parts.push(format!("{} GB RAM @ {}MHz", total_mem_gb, speed)); | ||||||
|  |             } else { | ||||||
|  |                 parts.push(format!("{} GB RAM", total_mem_gb)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Part 4: Storage Information
 | ||||||
|  |         if !self.storage.is_empty() { | ||||||
|  |             let total_storage_bytes = self.storage.iter().map(|d| d.size_bytes).sum::<u64>(); | ||||||
|  |             let drive_count = self.storage.len(); | ||||||
|  |             let first_drive_model = &self.storage[0].model; | ||||||
|  | 
 | ||||||
|  |             // Helper to format bytes into TB or GB
 | ||||||
|  |             let format_storage = |bytes: u64| { | ||||||
|  |                 let tb = bytes as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0); | ||||||
|  |                 if tb >= 1.0 { | ||||||
|  |                     format!("{:.2} TB", tb) | ||||||
|  |                 } else { | ||||||
|  |                     let gb = bytes as f64 / (1024.0 * 1024.0 * 1024.0); | ||||||
|  |                     format!("{:.0} GB", gb) | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  | 
 | ||||||
|  |             let storage_summary = if drive_count > 1 { | ||||||
|  |                 format!( | ||||||
|  |                     "{} Storage ({}x {})", | ||||||
|  |                     format_storage(total_storage_bytes), | ||||||
|  |                     drive_count, | ||||||
|  |                     first_drive_model | ||||||
|  |                 ) | ||||||
|  |             } else { | ||||||
|  |                 format!( | ||||||
|  |                     "{} Storage ({})", | ||||||
|  |                     format_storage(total_storage_bytes), | ||||||
|  |                     first_drive_model | ||||||
|  |                 ) | ||||||
|  |             }; | ||||||
|  |             parts.push(storage_summary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Part 5: Network Information
 | ||||||
|  |         // Prioritize an "up" interface with an IPv4 address
 | ||||||
|  |         let best_nic = self | ||||||
|  |             .network | ||||||
|  |             .iter() | ||||||
|  |             .find(|n| n.is_up && !n.ipv4_addresses.is_empty()) | ||||||
|  |             .or_else(|| self.network.first()); | ||||||
|  | 
 | ||||||
|  |         if let Some(nic) = best_nic { | ||||||
|  |             let speed = nic | ||||||
|  |                 .speed_mbps | ||||||
|  |                 .map(|s| format!("{}Gbps", s / 1000)) | ||||||
|  |                 .unwrap_or_else(|| "N/A".to_string()); | ||||||
|  |             let mac = nic.mac_address.to_string(); | ||||||
|  |             let nic_summary = if let Some(ip) = nic.ipv4_addresses.first() { | ||||||
|  |                 format!("NIC: {} ({}, {})", speed, ip, mac) | ||||||
|  |             } else { | ||||||
|  |                 format!("NIC: {} ({})", speed, mac) | ||||||
|  |             }; | ||||||
|  |             parts.push(nic_summary); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         parts.join(" | ") | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     pub fn cluster_mac(&self) -> MacAddress { |     pub fn cluster_mac(&self) -> MacAddress { | ||||||
|         self.network |         self.network | ||||||
|             .first() |             .first() | ||||||
| @ -42,37 +158,17 @@ impl PhysicalHost { | |||||||
|             .mac_address |             .mac_address | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn cpu(mut self, cpu_count: Option<u64>) -> Self { |  | ||||||
|         self.cpu_count = cpu_count; |  | ||||||
|         self |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn memory_size(mut self, memory_size: Option<u64>) -> Self { |  | ||||||
|         self.memory_size = memory_size; |  | ||||||
|         self |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn storage( |  | ||||||
|         mut self, |  | ||||||
|         connection: StorageConnectionType, |  | ||||||
|         kind: StorageKind, |  | ||||||
|         size: u64, |  | ||||||
|         serial: String, |  | ||||||
|     ) -> Self { |  | ||||||
|         self.storage.push(Storage { |  | ||||||
|             connection, |  | ||||||
|             kind, |  | ||||||
|             size, |  | ||||||
|             serial, |  | ||||||
|         }); |  | ||||||
|         self |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub fn mac_address(mut self, mac_address: MacAddress) -> Self { |     pub fn mac_address(mut self, mac_address: MacAddress) -> Self { | ||||||
|         self.network.push(NetworkInterface { |         self.network.push(NetworkInterface { | ||||||
|             name: None, |             name: String::new(), | ||||||
|             mac_address, |             mac_address, | ||||||
|             speed: None, |             speed_mbps: None, | ||||||
|  |             is_up: false, | ||||||
|  |             mtu: 0, | ||||||
|  |             ipv4_addresses: vec![], | ||||||
|  |             ipv6_addresses: vec![], | ||||||
|  |             driver: String::new(), | ||||||
|  |             firmware_version: None, | ||||||
|         }); |         }); | ||||||
|         self |         self | ||||||
|     } |     } | ||||||
| @ -81,57 +177,52 @@ impl PhysicalHost { | |||||||
|         self.labels.push(Label { name, value }); |         self.labels.push(Label { name, value }); | ||||||
|         self |         self | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     pub fn management(mut self, management: Arc<dyn ManagementInterface>) -> Self { |  | ||||||
|         self.management = management; |  | ||||||
|         self |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Custom Serialize implementation for PhysicalHost
 | // Custom Serialize implementation for PhysicalHost
 | ||||||
| impl Serialize for PhysicalHost { | // impl Serialize for PhysicalHost {
 | ||||||
|     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | //     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
 | ||||||
|     where | //     where
 | ||||||
|         S: Serializer, | //         S: Serializer,
 | ||||||
|     { | //     {
 | ||||||
|         // Determine the number of fields
 | //         // Determine the number of fields
 | ||||||
|         let mut num_fields = 5; // category, network, storage, labels, management
 | //         let mut num_fields = 5; // category, network, storage, labels, management
 | ||||||
|         if self.memory_size.is_some() { | //         if self.memory_modules.is_some() {
 | ||||||
|             num_fields += 1; | //             num_fields += 1;
 | ||||||
|         } | //         }
 | ||||||
|         if self.cpu_count.is_some() { | //         if self.cpus.is_some() {
 | ||||||
|             num_fields += 1; | //             num_fields += 1;
 | ||||||
|         } | //         }
 | ||||||
| 
 | //
 | ||||||
|         // Create a serialization structure
 | //         // Create a serialization structure
 | ||||||
|         let mut state = serializer.serialize_struct("PhysicalHost", num_fields)?; | //         let mut state = serializer.serialize_struct("PhysicalHost", num_fields)?;
 | ||||||
| 
 | //
 | ||||||
|         // Serialize the standard fields
 | //         // Serialize the standard fields
 | ||||||
|         state.serialize_field("category", &self.category)?; | //         state.serialize_field("category", &self.category)?;
 | ||||||
|         state.serialize_field("network", &self.network)?; | //         state.serialize_field("network", &self.network)?;
 | ||||||
|         state.serialize_field("storage", &self.storage)?; | //         state.serialize_field("storage", &self.storage)?;
 | ||||||
|         state.serialize_field("labels", &self.labels)?; | //         state.serialize_field("labels", &self.labels)?;
 | ||||||
| 
 | //
 | ||||||
|         // Serialize optional fields
 | //         // Serialize optional fields
 | ||||||
|         if let Some(memory) = self.memory_size { | //         if let Some(memory) = self.memory_modules {
 | ||||||
|             state.serialize_field("memory_size", &memory)?; | //             state.serialize_field("memory_size", &memory)?;
 | ||||||
|         } | //         }
 | ||||||
|         if let Some(cpu) = self.cpu_count { | //         if let Some(cpu) = self.cpus {
 | ||||||
|             state.serialize_field("cpu_count", &cpu)?; | //             state.serialize_field("cpu_count", &cpu)?;
 | ||||||
|         } | //         }
 | ||||||
| 
 | //
 | ||||||
|         let mgmt_data = self.management.serialize_management(); | //         let mgmt_data = self.management.serialize_management();
 | ||||||
|         // pub management: Arc<dyn ManagementInterface>,
 | //         // pub management: Arc<dyn ManagementInterface>,
 | ||||||
| 
 | //
 | ||||||
|         // Handle management interface - either as a field or flattened
 | //         // Handle management interface - either as a field or flattened
 | ||||||
|         state.serialize_field("management", &mgmt_data)?; | //         state.serialize_field("management", &mgmt_data)?;
 | ||||||
| 
 | //
 | ||||||
|         state.end() | //         state.end()
 | ||||||
|     } | //     }
 | ||||||
| } | // }
 | ||||||
| 
 | 
 | ||||||
| impl<'de> Deserialize<'de> for PhysicalHost { | impl<'de> Deserialize<'de> for PhysicalHost { | ||||||
|     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |     fn deserialize<D>(_deserializer: D) -> Result<Self, D::Error> | ||||||
|     where |     where | ||||||
|         D: serde::Deserializer<'de>, |         D: serde::Deserializer<'de>, | ||||||
|     { |     { | ||||||
| @ -189,61 +280,10 @@ pub enum HostCategory { | |||||||
|     Switch, |     Switch, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, new, Clone, Serialize)] |  | ||||||
| pub struct NetworkInterface { |  | ||||||
|     pub name: Option<String>, |  | ||||||
|     pub mac_address: MacAddress, |  | ||||||
|     pub speed: Option<u64>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| use harmony_macros::mac_address; | use harmony_macros::mac_address; | ||||||
| 
 | 
 | ||||||
| use harmony_types::id::Id; | use harmony_types::id::Id; | ||||||
| #[cfg(test)] |  | ||||||
| impl NetworkInterface { |  | ||||||
|     pub fn dummy() -> Self { |  | ||||||
|         Self { |  | ||||||
|             name: Some(String::new()), |  | ||||||
|             mac_address: mac_address!("00:00:00:00:00:00"), |  | ||||||
|             speed: Some(0), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Debug, new, Clone, Serialize)] |  | ||||||
| pub enum StorageConnectionType { |  | ||||||
|     Sata3g, |  | ||||||
|     Sata6g, |  | ||||||
|     Sas6g, |  | ||||||
|     Sas12g, |  | ||||||
|     PCIE, |  | ||||||
| } |  | ||||||
| #[derive(Debug, Clone, Serialize)] |  | ||||||
| pub enum StorageKind { |  | ||||||
|     SSD, |  | ||||||
|     NVME, |  | ||||||
|     HDD, |  | ||||||
| } |  | ||||||
| #[derive(Debug, new, Clone, Serialize)] |  | ||||||
| pub struct Storage { |  | ||||||
|     pub connection: StorageConnectionType, |  | ||||||
|     pub kind: StorageKind, |  | ||||||
|     pub size: u64, |  | ||||||
|     pub serial: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[cfg(test)] |  | ||||||
| impl Storage { |  | ||||||
|     pub fn dummy() -> Self { |  | ||||||
|         Self { |  | ||||||
|             connection: StorageConnectionType::Sata3g, |  | ||||||
|             kind: StorageKind::SSD, |  | ||||||
|             size: 0, |  | ||||||
|             serial: String::new(), |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Clone, Serialize)] | #[derive(Debug, Clone, Serialize)] | ||||||
| pub struct Switch { | pub struct Switch { | ||||||
| @ -274,117 +314,43 @@ impl Location { | |||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl std::fmt::Display for HostCategory { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         match self { | ||||||
|  |             HostCategory::Server => write!(f, "Server"), | ||||||
|  |             HostCategory::Firewall => write!(f, "Firewall"), | ||||||
|  |             HostCategory::Switch => write!(f, "Switch"), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl std::fmt::Display for Label { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         write!(f, "{}: {}", self.name, self.value) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl std::fmt::Display for Location { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         write!(f, "Address: {}, Name: {}", self.address, self.name) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl std::fmt::Display for PhysicalHost { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         write!(f, "{}", self.summary()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl std::fmt::Display for Switch { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         write!(f, "Switch with {} interfaces", self._interface.len()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod tests { | mod tests { | ||||||
|     use super::*; |     use super::*; | ||||||
|     use serde::{Deserialize, Serialize}; |  | ||||||
|     use std::sync::Arc; |  | ||||||
| 
 |  | ||||||
|     // Mock implementation of ManagementInterface
 |  | ||||||
|     #[derive(Debug, Clone, Serialize, Deserialize)] |  | ||||||
|     struct MockHPIlo { |  | ||||||
|         ip: String, |  | ||||||
|         username: String, |  | ||||||
|         password: String, |  | ||||||
|         firmware_version: String, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     impl ManagementInterface for MockHPIlo { |  | ||||||
|         fn boot_to_pxe(&self) {} |  | ||||||
| 
 |  | ||||||
|         fn get_supported_protocol_names(&self) -> String { |  | ||||||
|             String::new() |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Another mock implementation
 |  | ||||||
|     #[derive(Debug, Clone, Serialize, Deserialize)] |  | ||||||
|     struct MockDellIdrac { |  | ||||||
|         hostname: String, |  | ||||||
|         port: u16, |  | ||||||
|         api_token: String, |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     impl ManagementInterface for MockDellIdrac { |  | ||||||
|         fn boot_to_pxe(&self) {} |  | ||||||
| 
 |  | ||||||
|         fn get_supported_protocol_names(&self) -> String { |  | ||||||
|             String::new() |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[test] |  | ||||||
|     fn test_serialize_physical_host_with_hp_ilo() { |  | ||||||
|         // Create a PhysicalHost with HP iLO management
 |  | ||||||
|         let host = PhysicalHost { |  | ||||||
|             id: Id::empty(), |  | ||||||
|             category: HostCategory::Server, |  | ||||||
|             network: vec![NetworkInterface::dummy()], |  | ||||||
|             management: Arc::new(MockHPIlo { |  | ||||||
|                 ip: "192.168.1.100".to_string(), |  | ||||||
|                 username: "admin".to_string(), |  | ||||||
|                 password: "password123".to_string(), |  | ||||||
|                 firmware_version: "2.5.0".to_string(), |  | ||||||
|             }), |  | ||||||
|             storage: vec![Storage::dummy()], |  | ||||||
|             labels: vec![Label::new("datacenter".to_string(), "us-east".to_string())], |  | ||||||
|             memory_size: Some(64_000_000), |  | ||||||
|             cpu_count: Some(16), |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         // Serialize to JSON
 |  | ||||||
|         let json = serde_json::to_string(&host).expect("Failed to serialize host"); |  | ||||||
| 
 |  | ||||||
|         // Check that the serialized JSON contains the HP iLO details
 |  | ||||||
|         assert!(json.contains("192.168.1.100")); |  | ||||||
|         assert!(json.contains("admin")); |  | ||||||
|         assert!(json.contains("password123")); |  | ||||||
|         assert!(json.contains("firmware_version")); |  | ||||||
|         assert!(json.contains("2.5.0")); |  | ||||||
| 
 |  | ||||||
|         // Parse back to verify structure (not the exact management interface)
 |  | ||||||
|         let parsed: serde_json::Value = serde_json::from_str(&json).expect("Failed to parse JSON"); |  | ||||||
| 
 |  | ||||||
|         // Verify basic structure
 |  | ||||||
|         assert_eq!(parsed["cpu_count"], 16); |  | ||||||
|         assert_eq!(parsed["memory_size"], 64_000_000); |  | ||||||
|         assert_eq!(parsed["network"][0]["name"], ""); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     #[test] |  | ||||||
|     fn test_serialize_physical_host_with_dell_idrac() { |  | ||||||
|         // Create a PhysicalHost with Dell iDRAC management
 |  | ||||||
|         let host = PhysicalHost { |  | ||||||
|             id: Id::empty(), |  | ||||||
|             category: HostCategory::Server, |  | ||||||
|             network: vec![NetworkInterface::dummy()], |  | ||||||
|             management: Arc::new(MockDellIdrac { |  | ||||||
|                 hostname: "idrac-server01".to_string(), |  | ||||||
|                 port: 443, |  | ||||||
|                 api_token: "abcdef123456".to_string(), |  | ||||||
|             }), |  | ||||||
|             storage: vec![Storage::dummy()], |  | ||||||
|             labels: vec![Label::new("env".to_string(), "production".to_string())], |  | ||||||
|             memory_size: Some(128_000_000), |  | ||||||
|             cpu_count: Some(32), |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         // Serialize to JSON
 |  | ||||||
|         let json = serde_json::to_string(&host).expect("Failed to serialize host"); |  | ||||||
| 
 |  | ||||||
|         // Check that the serialized JSON contains the Dell iDRAC details
 |  | ||||||
|         assert!(json.contains("idrac-server01")); |  | ||||||
|         assert!(json.contains("443")); |  | ||||||
|         assert!(json.contains("abcdef123456")); |  | ||||||
| 
 |  | ||||||
|         // Parse back to verify structure
 |  | ||||||
|         let parsed: serde_json::Value = serde_json::from_str(&json).expect("Failed to parse JSON"); |  | ||||||
| 
 |  | ||||||
|         // Verify basic structure
 |  | ||||||
|         assert_eq!(parsed["cpu_count"], 32); |  | ||||||
|         assert_eq!(parsed["memory_size"], 128_000_000); |  | ||||||
|         assert_eq!(parsed["storage"][0]["path"], serde_json::Value::Null); |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     #[test] |     #[test] | ||||||
|     fn test_different_management_implementations_produce_valid_json() { |     fn test_different_management_implementations_produce_valid_json() { | ||||||
| @ -393,31 +359,20 @@ mod tests { | |||||||
|             id: Id::empty(), |             id: Id::empty(), | ||||||
|             category: HostCategory::Server, |             category: HostCategory::Server, | ||||||
|             network: vec![], |             network: vec![], | ||||||
|             management: Arc::new(MockHPIlo { |  | ||||||
|                 ip: "10.0.0.1".to_string(), |  | ||||||
|                 username: "root".to_string(), |  | ||||||
|                 password: "secret".to_string(), |  | ||||||
|                 firmware_version: "3.0.0".to_string(), |  | ||||||
|             }), |  | ||||||
|             storage: vec![], |             storage: vec![], | ||||||
|             labels: vec![], |             labels: vec![], | ||||||
|             memory_size: None, |             memory_modules: vec![], | ||||||
|             cpu_count: None, |             cpus: vec![], | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let host2 = PhysicalHost { |         let host2 = PhysicalHost { | ||||||
|             id: Id::empty(), |             id: Id::empty(), | ||||||
|             category: HostCategory::Server, |             category: HostCategory::Server, | ||||||
|             network: vec![], |             network: vec![], | ||||||
|             management: Arc::new(MockDellIdrac { |  | ||||||
|                 hostname: "server02-idrac".to_string(), |  | ||||||
|                 port: 8443, |  | ||||||
|                 api_token: "token123".to_string(), |  | ||||||
|             }), |  | ||||||
|             storage: vec![], |             storage: vec![], | ||||||
|             labels: vec![], |             labels: vec![], | ||||||
|             memory_size: None, |             memory_modules: vec![], | ||||||
|             cpu_count: None, |             cpus: vec![], | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         // Both should serialize successfully
 |         // Both should serialize successfully
 | ||||||
| @ -427,8 +382,5 @@ mod tests { | |||||||
|         // Both JSONs should be valid and parseable
 |         // Both JSONs should be valid and parseable
 | ||||||
|         let _: serde_json::Value = serde_json::from_str(&json1).expect("Invalid JSON for host1"); |         let _: serde_json::Value = serde_json::from_str(&json1).expect("Invalid JSON for host1"); | ||||||
|         let _: serde_json::Value = serde_json::from_str(&json2).expect("Invalid JSON for host2"); |         let _: serde_json::Value = serde_json::from_str(&json2).expect("Invalid JSON for host2"); | ||||||
| 
 |  | ||||||
|         // The JSONs should be different because they contain different management interfaces
 |  | ||||||
|         assert_ne!(json1, json2); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,6 +18,8 @@ impl InventoryFilter { | |||||||
| use derive_new::new; | use derive_new::new; | ||||||
| use log::info; | use log::info; | ||||||
| 
 | 
 | ||||||
|  | use crate::hardware::{ManagementInterface, ManualManagementInterface}; | ||||||
|  | 
 | ||||||
| use super::{ | use super::{ | ||||||
|     filter::Filter, |     filter::Filter, | ||||||
|     hardware::{FirewallGroup, HostGroup, Location, SwitchGroup}, |     hardware::{FirewallGroup, HostGroup, Location, SwitchGroup}, | ||||||
| @ -30,7 +32,7 @@ pub struct Inventory { | |||||||
|     // Firewall is really just a host but with somewhat specialized hardware
 |     // Firewall is really just a host but with somewhat specialized hardware
 | ||||||
|     // I'm not entirely sure it belongs to its own category but it helps make things easier and
 |     // I'm not entirely sure it belongs to its own category but it helps make things easier and
 | ||||||
|     // clearer for now so let's try it this way.
 |     // clearer for now so let's try it this way.
 | ||||||
|     pub firewall: FirewallGroup, |     pub firewall_mgmt: Box<dyn ManagementInterface>, | ||||||
|     pub worker_host: HostGroup, |     pub worker_host: HostGroup, | ||||||
|     pub storage_host: HostGroup, |     pub storage_host: HostGroup, | ||||||
|     pub control_plane_host: HostGroup, |     pub control_plane_host: HostGroup, | ||||||
| @ -41,7 +43,7 @@ impl Inventory { | |||||||
|         Self { |         Self { | ||||||
|             location: Location::new("Empty".to_string(), "location".to_string()), |             location: Location::new("Empty".to_string(), "location".to_string()), | ||||||
|             switch: vec![], |             switch: vec![], | ||||||
|             firewall: vec![], |             firewall_mgmt: Box::new(ManualManagementInterface {}), | ||||||
|             worker_host: vec![], |             worker_host: vec![], | ||||||
|             storage_host: vec![], |             storage_host: vec![], | ||||||
|             control_plane_host: vec![], |             control_plane_host: vec![], | ||||||
| @ -52,7 +54,7 @@ impl Inventory { | |||||||
|         Self { |         Self { | ||||||
|             location: Location::test_building(), |             location: Location::test_building(), | ||||||
|             switch: SwitchGroup::new(), |             switch: SwitchGroup::new(), | ||||||
|             firewall: FirewallGroup::new(), |             firewall_mgmt: Box::new(ManualManagementInterface {}), | ||||||
|             worker_host: HostGroup::new(), |             worker_host: HostGroup::new(), | ||||||
|             storage_host: HostGroup::new(), |             storage_host: HostGroup::new(), | ||||||
|             control_plane_host: HostGroup::new(), |             control_plane_host: HostGroup::new(), | ||||||
|  | |||||||
| @ -1 +1,17 @@ | |||||||
| mod sqlite; | use crate::{ | ||||||
|  |     config::DATABASE_URL, | ||||||
|  |     infra::inventory::sqlite::SqliteInventoryRepository, | ||||||
|  |     inventory::{InventoryRepository, RepoError}, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | pub mod sqlite; | ||||||
|  | 
 | ||||||
|  | pub struct InventoryRepositoryFactory; | ||||||
|  | 
 | ||||||
|  | impl InventoryRepositoryFactory { | ||||||
|  |     pub async fn build() -> Result<Box<dyn InventoryRepository>, RepoError> { | ||||||
|  |         Ok(Box::new( | ||||||
|  |             SqliteInventoryRepository::new(&(*DATABASE_URL)).await?, | ||||||
|  |         )) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | |||||||
| @ -19,11 +19,7 @@ impl SqliteInventoryRepository { | |||||||
|             .await |             .await | ||||||
|             .map_err(|e| RepoError::ConnectionFailed(e.to_string()))?; |             .map_err(|e| RepoError::ConnectionFailed(e.to_string()))?; | ||||||
| 
 | 
 | ||||||
|         todo!("make sure migrations are up to date"); |         info!("SQLite inventory repository initialized at '{database_url}'"); | ||||||
|         info!( |  | ||||||
|             "SQLite inventory repository initialized at '{}'", |  | ||||||
|             database_url, |  | ||||||
|         ); |  | ||||||
|         Ok(Self { pool }) |         Ok(Self { pool }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @ -50,7 +46,7 @@ impl InventoryRepository for SqliteInventoryRepository { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async fn get_latest_by_id(&self, host_id: &str) -> Result<Option<PhysicalHost>, RepoError> { |     async fn get_latest_by_id(&self, host_id: &str) -> Result<Option<PhysicalHost>, RepoError> { | ||||||
|         let row = sqlx::query_as!( |         let _row = sqlx::query_as!( | ||||||
|             DbHost, |             DbHost, | ||||||
|             r#"SELECT id, version_id, data as "data: Json<PhysicalHost>" FROM physical_hosts WHERE id = ? ORDER BY version_id DESC LIMIT 1"#, |             r#"SELECT id, version_id, data as "data: Json<PhysicalHost>" FROM physical_hosts WHERE id = ? ORDER BY version_id DESC LIMIT 1"#, | ||||||
|             host_id |             host_id | ||||||
|  | |||||||
| @ -1,10 +1,12 @@ | |||||||
| use async_trait::async_trait; | use async_trait::async_trait; | ||||||
| use harmony_inventory_agent::local_presence::DiscoveryEvent; | use harmony_inventory_agent::local_presence::DiscoveryEvent; | ||||||
| use log::{debug, info}; | use log::{debug, info, trace}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     data::Version, |     data::Version, | ||||||
|  |     hardware::{HostCategory, Label, PhysicalHost}, | ||||||
|  |     infra::inventory::InventoryRepositoryFactory, | ||||||
|     interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, |     interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, | ||||||
|     inventory::Inventory, |     inventory::Inventory, | ||||||
|     score::Score, |     score::Score, | ||||||
| @ -41,20 +43,89 @@ struct DiscoverInventoryAgentInterpret { | |||||||
| impl<T: Topology> Interpret<T> for DiscoverInventoryAgentInterpret { | impl<T: Topology> Interpret<T> for DiscoverInventoryAgentInterpret { | ||||||
|     async fn execute( |     async fn execute( | ||||||
|         &self, |         &self, | ||||||
|         inventory: &Inventory, |         _inventory: &Inventory, | ||||||
|         topology: &T, |         _topology: &T, | ||||||
|     ) -> Result<Outcome, InterpretError> { |     ) -> Result<Outcome, InterpretError> { | ||||||
|         harmony_inventory_agent::local_presence::discover_agents( |         harmony_inventory_agent::local_presence::discover_agents( | ||||||
|             self.score.discovery_timeout, |             self.score.discovery_timeout, | ||||||
|             |event: DiscoveryEvent| { |             |event: DiscoveryEvent| -> Result<(), String> { | ||||||
|                 println!("Discovery event {event:?}"); |                 debug!("Discovery event {event:?}"); | ||||||
|                 match event { |                 match event { | ||||||
|                     DiscoveryEvent::ServiceResolved(service) => info!("Found instance {service:?}"), |                     DiscoveryEvent::ServiceResolved(service) => { | ||||||
|                     _ => debug!("Unhandled event {event:?}"), |                         let service_name = service.fullname.clone(); | ||||||
|  |                         info!("Found service {service_name}"); | ||||||
|  | 
 | ||||||
|  |                         let address = match service.get_addresses().iter().next() { | ||||||
|  |                             Some(address) => address, | ||||||
|  |                             None => { | ||||||
|  |                                 return Err(format!( | ||||||
|  |                                     "Could not find address for service {service_name}" | ||||||
|  |                                 )); | ||||||
|                             } |                             } | ||||||
|             }, |                         }; | ||||||
|  | 
 | ||||||
|  |                         let address = address.to_string(); | ||||||
|  |                         let port = service.get_port(); | ||||||
|  | 
 | ||||||
|  |                         tokio::task::spawn(async move { | ||||||
|  |                             info!("Getting inventory for host {address} at port {port}"); | ||||||
|  |                             let host = | ||||||
|  |                                 harmony_inventory_agent::client::get_host_inventory(&address, port) | ||||||
|  |                                     .await | ||||||
|  |                                     .unwrap(); | ||||||
|  | 
 | ||||||
|  |                             trace!("Found host information {host:?}"); | ||||||
|  |                             // TODO its useless to have two distinct host types but requires a bit much
 | ||||||
|  |                             // refactoring to do it now
 | ||||||
|  |                             let harmony_inventory_agent::hwinfo::PhysicalHost { | ||||||
|  |                                 storage_drives, | ||||||
|  |                                 storage_controller, | ||||||
|  |                                 memory_modules, | ||||||
|  |                                 cpus, | ||||||
|  |                                 chipset, | ||||||
|  |                                 network_interfaces, | ||||||
|  |                                 management_interface, | ||||||
|  |                                 host_uuid, | ||||||
|  |                             } = host; | ||||||
|  | 
 | ||||||
|  |                             let host = PhysicalHost { | ||||||
|  |                                 id: Id::from(host_uuid), | ||||||
|  |                                 category: HostCategory::Server, | ||||||
|  |                                 network: network_interfaces, | ||||||
|  |                                 storage: storage_drives, | ||||||
|  |                                 labels: vec![Label { | ||||||
|  |                                     name: "discovered-by".to_string(), | ||||||
|  |                                     value: "harmony-inventory-agent".to_string(), | ||||||
|  |                                 }], | ||||||
|  |                                 memory_modules, | ||||||
|  |                                 cpus, | ||||||
|  |                             }; | ||||||
|  | 
 | ||||||
|  |                             let repo = InventoryRepositoryFactory::build() | ||||||
|  |                                 .await | ||||||
|  |                                 .map_err(|e| format!("Could not build repository : {e}")) | ||||||
|  |                                 .unwrap(); | ||||||
|  |                             repo.save(&host) | ||||||
|  |                                 .await | ||||||
|  |                                 .map_err(|e| format!("Could not save host : {e}")) | ||||||
|  |                                 .unwrap(); | ||||||
|  |                             info!( | ||||||
|  |                                 "Saved new host id {}, summary : {}", | ||||||
|  |                                 host.id, | ||||||
|  |                                 host.summary() | ||||||
|                             ); |                             ); | ||||||
|         todo!() |                         }); | ||||||
|  |                     } | ||||||
|  |                     _ => debug!("Unhandled event {event:?}"), | ||||||
|  |                 }; | ||||||
|  |                 Ok(()) | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  |         Ok(Outcome { | ||||||
|  |             status: InterpretStatus::SUCCESS, | ||||||
|  |             message: "Discovery process completed successfully".to_string(), | ||||||
|  |         }) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fn get_name(&self) -> InterpretName { |     fn get_name(&self) -> InterpretName { | ||||||
|  | |||||||
| @ -12,6 +12,9 @@ log.workspace = true | |||||||
| env_logger.workspace = true | env_logger.workspace = true | ||||||
| tokio.workspace = true | tokio.workspace = true | ||||||
| thiserror.workspace = true | thiserror.workspace = true | ||||||
|  | reqwest.workspace = true | ||||||
| # mdns-sd = "0.14.1" | # mdns-sd = "0.14.1" | ||||||
| mdns-sd = { git = "https://github.com/jggc/mdns-sd.git", branch = "patch-1" } | mdns-sd = { git = "https://github.com/jggc/mdns-sd.git", branch = "patch-1" } | ||||||
| local-ip-address = "0.6.5" | local-ip-address = "0.6.5" | ||||||
|  | harmony_types = { path = "../harmony_types" } | ||||||
|  | harmony_macros = { path = "../harmony_macros" } | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								harmony_inventory_agent/src/client.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								harmony_inventory_agent/src/client.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | |||||||
|  | use crate::hwinfo::PhysicalHost; | ||||||
|  | 
 | ||||||
|  | pub async fn get_host_inventory(host: &str, port: u16) -> Result<PhysicalHost, String> { | ||||||
|  |     let url = format!("http://{host}:{port}/inventory"); | ||||||
|  |     let client = reqwest::Client::new(); | ||||||
|  |     let response = client | ||||||
|  |         .get(url) | ||||||
|  |         .send() | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| format!("Failed to download file: {e}"))?; | ||||||
|  | 
 | ||||||
|  |     let host = response.json().await.map_err(|e| e.to_string())?; | ||||||
|  | 
 | ||||||
|  |     Ok(host) | ||||||
|  | } | ||||||
| @ -1,3 +1,4 @@ | |||||||
|  | use harmony_types::net::MacAddress; | ||||||
| use log::{debug, warn}; | use log::{debug, warn}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use serde_json::Value; | use serde_json::Value; | ||||||
| @ -18,7 +19,7 @@ pub struct PhysicalHost { | |||||||
|     pub host_uuid: String, |     pub host_uuid: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug, Clone)] | ||||||
| pub struct StorageDrive { | pub struct StorageDrive { | ||||||
|     pub name: String, |     pub name: String, | ||||||
|     pub model: String, |     pub model: String, | ||||||
| @ -32,13 +33,30 @@ pub struct StorageDrive { | |||||||
|     pub smart_status: Option<String>, |     pub smart_status: Option<String>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl StorageDrive { | ||||||
|  |     pub fn dummy() -> Self { | ||||||
|  |         Self { | ||||||
|  |             name: String::new(), | ||||||
|  |             model: String::new(), | ||||||
|  |             serial: String::new(), | ||||||
|  |             size_bytes: 0, | ||||||
|  |             logical_block_size: 0, | ||||||
|  |             physical_block_size: 0, | ||||||
|  |             rotational: false, | ||||||
|  |             wwn: None, | ||||||
|  |             interface_type: String::new(), | ||||||
|  |             smart_status: None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug)] | ||||||
| pub struct StorageController { | pub struct StorageController { | ||||||
|     pub name: String, |     pub name: String, | ||||||
|     pub driver: String, |     pub driver: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug, Clone)] | ||||||
| pub struct MemoryModule { | pub struct MemoryModule { | ||||||
|     pub size_bytes: u64, |     pub size_bytes: u64, | ||||||
|     pub speed_mhz: Option<u32>, |     pub speed_mhz: Option<u32>, | ||||||
| @ -48,7 +66,7 @@ pub struct MemoryModule { | |||||||
|     pub rank: Option<u8>, |     pub rank: Option<u8>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug, Clone)] | ||||||
| pub struct CPU { | pub struct CPU { | ||||||
|     pub model: String, |     pub model: String, | ||||||
|     pub vendor: String, |     pub vendor: String, | ||||||
| @ -63,10 +81,10 @@ pub struct Chipset { | |||||||
|     pub vendor: String, |     pub vendor: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug, Clone)] | ||||||
| pub struct NetworkInterface { | pub struct NetworkInterface { | ||||||
|     pub name: String, |     pub name: String, | ||||||
|     pub mac_address: String, |     pub mac_address: MacAddress, | ||||||
|     pub speed_mbps: Option<u32>, |     pub speed_mbps: Option<u32>, | ||||||
|     pub is_up: bool, |     pub is_up: bool, | ||||||
|     pub mtu: u32, |     pub mtu: u32, | ||||||
| @ -76,6 +94,24 @@ pub struct NetworkInterface { | |||||||
|     pub firmware_version: Option<String>, |     pub firmware_version: Option<String>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl NetworkInterface { | ||||||
|  |     pub fn dummy() -> Self { | ||||||
|  |         use harmony_macros::mac_address; | ||||||
|  | 
 | ||||||
|  |         Self { | ||||||
|  |             name: String::new(), | ||||||
|  |             mac_address: mac_address!("00:00:00:00:00:00"), | ||||||
|  |             speed_mbps: Some(0), | ||||||
|  |             is_up: false, | ||||||
|  |             mtu: 0, | ||||||
|  |             ipv4_addresses: vec![], | ||||||
|  |             ipv6_addresses: vec![], | ||||||
|  |             driver: String::new(), | ||||||
|  |             firmware_version: None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug)] | ||||||
| pub struct ManagementInterface { | pub struct ManagementInterface { | ||||||
|     pub kind: String, |     pub kind: String, | ||||||
| @ -509,6 +545,7 @@ impl PhysicalHost { | |||||||
| 
 | 
 | ||||||
|             let mac_address = Self::read_sysfs_string(&iface_path.join("address")) |             let mac_address = Self::read_sysfs_string(&iface_path.join("address")) | ||||||
|                 .map_err(|e| format!("Failed to read MAC address for {}: {}", iface_name, e))?; |                 .map_err(|e| format!("Failed to read MAC address for {}: {}", iface_name, e))?; | ||||||
|  |             let mac_address = MacAddress::try_from(mac_address).map_err(|e| e.to_string())?; | ||||||
| 
 | 
 | ||||||
|             let speed_mbps = if iface_path.join("speed").exists() { |             let speed_mbps = if iface_path.join("speed").exists() { | ||||||
|                 match Self::read_sysfs_u32(&iface_path.join("speed")) { |                 match Self::read_sysfs_u32(&iface_path.join("speed")) { | ||||||
|  | |||||||
| @ -1,2 +1,3 @@ | |||||||
| mod hwinfo; | pub mod client; | ||||||
|  | pub mod hwinfo; | ||||||
| pub mod local_presence; | pub mod local_presence; | ||||||
|  | |||||||
| @ -1,10 +1,14 @@ | |||||||
|  | use log::{debug, error}; | ||||||
| use mdns_sd::{ServiceDaemon, ServiceEvent}; | use mdns_sd::{ServiceDaemon, ServiceEvent}; | ||||||
| 
 | 
 | ||||||
| use crate::local_presence::SERVICE_NAME; | use crate::local_presence::SERVICE_NAME; | ||||||
| 
 | 
 | ||||||
| pub type DiscoveryEvent = ServiceEvent; | pub type DiscoveryEvent = ServiceEvent; | ||||||
| 
 | 
 | ||||||
| pub fn discover_agents(timeout: Option<u64>, on_event: impl Fn(DiscoveryEvent) + Send + 'static) { | pub async fn discover_agents<F>(timeout: Option<u64>, on_event: F) | ||||||
|  | where | ||||||
|  |     F: FnOnce(DiscoveryEvent) -> Result<(), String> + Send + 'static + Copy, | ||||||
|  | { | ||||||
|     // Create a new mDNS daemon.
 |     // Create a new mDNS daemon.
 | ||||||
|     let mdns = ServiceDaemon::new().expect("Failed to create mDNS daemon"); |     let mdns = ServiceDaemon::new().expect("Failed to create mDNS daemon"); | ||||||
| 
 | 
 | ||||||
| @ -12,23 +16,24 @@ pub fn discover_agents(timeout: Option<u64>, on_event: impl Fn(DiscoveryEvent) + | |||||||
|     // The receiver will be a stream of events.
 |     // The receiver will be a stream of events.
 | ||||||
|     let receiver = mdns.browse(SERVICE_NAME).expect("Failed to browse"); |     let receiver = mdns.browse(SERVICE_NAME).expect("Failed to browse"); | ||||||
| 
 | 
 | ||||||
|     std::thread::spawn(move || { |     tokio::task::spawn_blocking(move || { | ||||||
|         while let Ok(event) = receiver.recv() { |         while let Ok(event) = receiver.recv() { | ||||||
|             on_event(event.clone()); |             if let Err(e) = on_event(event.clone()) { | ||||||
|  |                 error!("Event callback failed : {e}"); | ||||||
|  |             } | ||||||
|             match event { |             match event { | ||||||
|                 ServiceEvent::ServiceResolved(resolved) => { |                 ServiceEvent::ServiceResolved(resolved) => { | ||||||
|                     println!("Resolved a new service: {}", resolved.fullname); |                     debug!("Resolved a new service: {}", resolved.fullname); | ||||||
|                 } |                 } | ||||||
|                 other_event => { |                 other_event => { | ||||||
|                     println!("Received other event: {:?}", &other_event); |                     debug!("Received other event: {:?}", &other_event); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     if let Some(timeout) = timeout { |     if let Some(timeout) = timeout { | ||||||
|         // Gracefully shutdown the daemon.
 |         tokio::time::sleep(std::time::Duration::from_secs(timeout)).await; | ||||||
|         std::thread::sleep(std::time::Duration::from_secs(timeout)); |  | ||||||
|         mdns.shutdown().unwrap(); |         mdns.shutdown().unwrap(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| use serde::Serialize; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
| #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)] | #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | ||||||
| pub struct MacAddress(pub [u8; 6]); | pub struct MacAddress(pub [u8; 6]); | ||||||
| 
 | 
 | ||||||
| impl MacAddress { | impl MacAddress { | ||||||
| @ -25,6 +25,30 @@ impl std::fmt::Display for MacAddress { | |||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl TryFrom<String> for MacAddress { | ||||||
|  |     type Error = std::io::Error; | ||||||
|  | 
 | ||||||
|  |     fn try_from(value: String) -> Result<Self, Self::Error> { | ||||||
|  |         let parts: Vec<&str> = value.split(':').collect(); | ||||||
|  |         if parts.len() != 6 { | ||||||
|  |             return Err(std::io::Error::new( | ||||||
|  |                 std::io::ErrorKind::InvalidInput, | ||||||
|  |                 "Invalid MAC address format: expected 6 colon-separated hex pairs", | ||||||
|  |             )); | ||||||
|  |         } | ||||||
|  |         let mut bytes = [0u8; 6]; | ||||||
|  |         for (i, part) in parts.iter().enumerate() { | ||||||
|  |             bytes[i] = u8::from_str_radix(part, 16).map_err(|_| { | ||||||
|  |                 std::io::Error::new( | ||||||
|  |                     std::io::ErrorKind::InvalidInput, | ||||||
|  |                     format!("Invalid hex value in part {}: '{}'", i, part), | ||||||
|  |                 ) | ||||||
|  |             })?; | ||||||
|  |         } | ||||||
|  |         Ok(MacAddress(bytes)) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| pub type IpAddress = std::net::IpAddr; | pub type IpAddress = std::net::IpAddr; | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, Clone)] | #[derive(Debug, Clone)] | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								migrations/20250830163356_Physical_hosts.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								migrations/20250830163356_Physical_hosts.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | |||||||
|  | -- Add migration script here | ||||||
|  | CREATE TABLE IF NOT EXISTS physical_hosts ( | ||||||
|  |     version_id TEXT PRIMARY KEY NOT NULL, | ||||||
|  |     id TEXT NOT NULL, | ||||||
|  |     data JSON NOT NULL | ||||||
|  | ); | ||||||
|  | CREATE INDEX IF NOT EXISTS idx_host_id_time | ||||||
|  | ON physical_hosts (id, version_id DESC); | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user