feat: Inventory PhysicalHost persistence with sqlx and local sqlite db
Some checks failed
Run Check Script / check (pull_request) Failing after 34s

This commit is contained in:
2025-08-30 16:02:49 -04:00
parent 1eca2cc1a9
commit cb4382fbb5
13 changed files with 560 additions and 4 deletions

View File

@@ -65,10 +65,12 @@ kube-derive = "1.1.0"
bollard.workspace = true
tar.workspace = true
base64.workspace = true
thiserror.workspace = true
once_cell = "1.21.3"
harmony_inventory_agent = { path = "../harmony_inventory_agent" }
harmony_secret_derive = { version = "0.1.0", path = "../harmony_secret_derive" }
askama = "0.14.0"
askama.workspace = true
sqlx.workspace = true
[dev-dependencies]
pretty_assertions.workspace = true

View File

@@ -24,6 +24,14 @@ pub struct Id {
value: String,
}
impl Id {
pub fn empty() -> Self {
Id {
value: String::new(),
}
}
}
impl FromStr for Id {
type Err = ();
@@ -34,6 +42,12 @@ impl FromStr for Id {
}
}
impl From<String> for Id {
fn from(value: String) -> Self {
Self { value }
}
}
impl std::fmt::Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.value)

View File

@@ -1,8 +1,8 @@
use std::sync::Arc;
use std::{str::FromStr, sync::Arc};
use derive_new::new;
use harmony_types::net::MacAddress;
use serde::{Serialize, Serializer, ser::SerializeStruct};
use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
use serde_value::Value;
pub type HostGroup = Vec<PhysicalHost>;
@@ -11,6 +11,7 @@ pub type FirewallGroup = Vec<PhysicalHost>;
#[derive(Debug, Clone)]
pub struct PhysicalHost {
pub id: Id,
pub category: HostCategory,
pub network: Vec<NetworkInterface>,
pub management: Arc<dyn ManagementInterface>,
@@ -23,6 +24,7 @@ pub struct PhysicalHost {
impl PhysicalHost {
pub fn empty(category: HostCategory) -> Self {
Self {
id: Id::empty(),
category,
network: vec![],
storage: vec![],
@@ -128,6 +130,15 @@ impl Serialize for PhysicalHost {
}
}
impl<'de> Deserialize<'de> for PhysicalHost {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
todo!()
}
}
#[derive(new, Serialize)]
pub struct ManualManagementInterface;
@@ -187,6 +198,8 @@ pub struct NetworkInterface {
#[cfg(test)]
use harmony_macros::mac_address;
use crate::data::Id;
#[cfg(test)]
impl NetworkInterface {
pub fn dummy() -> Self {
@@ -304,6 +317,7 @@ mod tests {
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 {
@@ -341,6 +355,7 @@ mod tests {
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 {
@@ -375,6 +390,7 @@ mod tests {
fn test_different_management_implementations_produce_valid_json() {
// Create hosts with different management implementations
let host1 = PhysicalHost {
id: Id::empty(),
category: HostCategory::Server,
network: vec![],
management: Arc::new(MockHPIlo {
@@ -390,6 +406,7 @@ mod tests {
};
let host2 = PhysicalHost {
id: Id::empty(),
category: HostCategory::Server,
network: vec![],
management: Arc::new(MockDellIdrac {

View File

@@ -1,3 +1,6 @@
mod repository;
pub use repository::*;
#[derive(Debug, new, Clone)]
pub struct InventoryFilter {
target: Vec<Filter>,

View File

@@ -0,0 +1,25 @@
use async_trait::async_trait;
use crate::hardware::PhysicalHost;
/// Errors that can occur within the repository layer.
#[derive(thiserror::Error, Debug)]
pub enum RepoError {
#[error("Database query failed: {0}")]
QueryFailed(String),
#[error("Data serialization failed: {0}")]
Serialization(String),
#[error("Data deserialization failed: {0}")]
Deserialization(String),
#[error("Could not connect to the database: {0}")]
ConnectionFailed(String),
}
// --- Trait and Implementation ---
/// Defines the contract for inventory persistence.
#[async_trait]
pub trait InventoryRepository: Send + Sync + 'static {
async fn save(&self, host: &PhysicalHost) -> Result<(), RepoError>;
async fn get_latest_by_id(&self, host_id: &str) -> Result<Option<PhysicalHost>, RepoError>;
}

View File

@@ -0,0 +1 @@
mod sqlite;

View File

@@ -0,0 +1,69 @@
use crate::{
data::Id,
hardware::PhysicalHost,
inventory::{InventoryRepository, RepoError},
};
use async_trait::async_trait;
use log::{info, warn};
use sqlx::{Pool, Sqlite, SqlitePool};
/// A thread-safe, connection-pooled repository using SQLite.
#[derive(Debug)]
pub struct SqliteInventoryRepository {
pool: Pool<Sqlite>,
}
impl SqliteInventoryRepository {
pub async fn new(database_url: &str) -> Result<Self, RepoError> {
let pool = SqlitePool::connect(database_url)
.await
.map_err(|e| RepoError::ConnectionFailed(e.to_string()))?;
todo!("make sure migrations are up to date");
info!(
"SQLite inventory repository initialized at '{}'",
database_url,
);
Ok(Self { pool })
}
}
#[async_trait]
impl InventoryRepository for SqliteInventoryRepository {
async fn save(&self, host: &PhysicalHost) -> Result<(), RepoError> {
let data = serde_json::to_vec(host).map_err(|e| RepoError::Serialization(e.to_string()))?;
let id = Id::default().to_string();
let host_id = host.id.to_string();
sqlx::query!(
"INSERT INTO physical_hosts (id, version_id, data) VALUES (?, ?, ?)",
host_id,
id,
data,
)
.execute(&self.pool)
.await?;
info!("Saved new inventory version for host '{}'", host.id);
Ok(())
}
async fn get_latest_by_id(&self, host_id: &str) -> Result<Option<PhysicalHost>, RepoError> {
let row = sqlx::query_as!(
DbHost,
r#"SELECT id, version_id, data as "data: Json<PhysicalHost>" FROM physical_hosts WHERE id = ? ORDER BY version_id DESC LIMIT 1"#,
host_id
)
.fetch_optional(&self.pool)
.await?;
todo!()
}
}
use sqlx::types::Json;
struct DbHost {
data: Json<PhysicalHost>,
id: Id,
version_id: Id,
}

View File

@@ -1,4 +1,6 @@
pub mod executors;
pub mod hp_ilo;
pub mod intel_amt;
pub mod inventory;
pub mod opnsense;
mod sqlx;

36
harmony/src/infra/sqlx.rs Normal file
View File

@@ -0,0 +1,36 @@
use crate::inventory::RepoError;
impl From<sqlx::Error> for RepoError {
fn from(value: sqlx::Error) -> Self {
match value {
sqlx::Error::Configuration(_)
| sqlx::Error::Io(_)
| sqlx::Error::Tls(_)
| sqlx::Error::Protocol(_)
| sqlx::Error::PoolTimedOut
| sqlx::Error::PoolClosed
| sqlx::Error::WorkerCrashed => RepoError::ConnectionFailed(value.to_string()),
sqlx::Error::InvalidArgument(_)
| sqlx::Error::Database(_)
| sqlx::Error::RowNotFound
| sqlx::Error::TypeNotFound { .. }
| sqlx::Error::ColumnIndexOutOfBounds { .. }
| sqlx::Error::ColumnNotFound(_)
| sqlx::Error::AnyDriverError(_)
| sqlx::Error::Migrate(_)
| sqlx::Error::InvalidSavePointStatement
| sqlx::Error::BeginFailed => RepoError::QueryFailed(value.to_string()),
sqlx::Error::Encode(_) => RepoError::Serialization(value.to_string()),
sqlx::Error::Decode(_) | sqlx::Error::ColumnDecode { .. } => {
RepoError::Deserialization(value.to_string())
}
_ => RepoError::QueryFailed(value.to_string()),
}
}
}
impl From<serde_json::Error> for RepoError {
fn from(value: serde_json::Error) -> Self {
RepoError::Serialization(value.to_string())
}
}