Initialize repository snapshot
This commit is contained in:
21
vaultmesh-mesh/Cargo.toml
Normal file
21
vaultmesh-mesh/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "vaultmesh-mesh"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
vaultmesh-core = { path = "../vaultmesh-core" }
|
||||
vaultmesh-observability = { path = "../vaultmesh-observability", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
metrics = ["vaultmesh-observability"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.8"
|
||||
tokio = { version = "1.28", features = ["rt-multi-thread", "macros", "time"] }
|
||||
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||
vaultmesh-observability = { path = "../vaultmesh-observability" }
|
||||
824
vaultmesh-mesh/src/lib.rs
Normal file
824
vaultmesh-mesh/src/lib.rs
Normal file
@@ -0,0 +1,824 @@
|
||||
//! VaultMesh Mesh Engine - Federation topology management
|
||||
//!
|
||||
//! The Mesh engine tracks nodes, routes, and capabilities in the federation
|
||||
//! network, emitting receipts for all topology changes.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(feature = "metrics")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "metrics")]
|
||||
use std::time::Instant;
|
||||
|
||||
use vaultmesh_core::{Scroll, VmHash};
|
||||
|
||||
#[cfg(feature = "metrics")]
|
||||
use vaultmesh_observability::ObservabilityEngine;
|
||||
|
||||
/// Schema version for mesh receipts
|
||||
pub const SCHEMA_VERSION: &str = "2.0.0";
|
||||
|
||||
/// Node type in the mesh
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NodeType {
|
||||
Infrastructure,
|
||||
Edge,
|
||||
Oracle,
|
||||
Guardian,
|
||||
External,
|
||||
}
|
||||
|
||||
/// Node status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NodeStatus {
|
||||
Active,
|
||||
Inactive,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
/// A node in the mesh
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Node {
|
||||
pub node_id: String,
|
||||
pub display_name: String,
|
||||
pub node_type: NodeType,
|
||||
pub endpoints: HashMap<String, String>,
|
||||
pub public_key: Option<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub status: NodeStatus,
|
||||
pub joined_at: DateTime<Utc>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Route status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouteStatus {
|
||||
Active,
|
||||
Degraded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A route between nodes
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Route {
|
||||
pub route_id: String,
|
||||
pub source: String,
|
||||
pub destination: String,
|
||||
pub transport: String,
|
||||
pub priority: u32,
|
||||
pub status: RouteStatus,
|
||||
pub latency_ms: Option<u32>,
|
||||
pub established_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Capability scope
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CapabilityScope {
|
||||
Global,
|
||||
Local,
|
||||
Limited,
|
||||
}
|
||||
|
||||
/// A capability granted to a node
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Capability {
|
||||
pub capability_id: String,
|
||||
pub node_id: String,
|
||||
pub capability: String,
|
||||
pub scope: CapabilityScope,
|
||||
pub granted_by: String,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Topology snapshot
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopologySnapshot {
|
||||
pub snapshot_id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub node_count: usize,
|
||||
pub route_count: usize,
|
||||
pub capability_count: usize,
|
||||
pub nodes: Vec<String>,
|
||||
pub topology_hash: String,
|
||||
}
|
||||
|
||||
/// Generic mesh receipt wrapper
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshReceipt {
|
||||
pub schema_version: String,
|
||||
#[serde(rename = "type")]
|
||||
pub receipt_type: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub scroll: String,
|
||||
pub tags: Vec<String>,
|
||||
pub root_hash: String,
|
||||
pub body: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Node join receipt body
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeJoinReceiptBody {
|
||||
pub node_id: String,
|
||||
pub display_name: String,
|
||||
pub node_type: NodeType,
|
||||
pub joined_by: String,
|
||||
}
|
||||
|
||||
/// Node leave receipt body
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeLeaveReceiptBody {
|
||||
pub node_id: String,
|
||||
pub reason: String,
|
||||
pub left_by: String,
|
||||
}
|
||||
|
||||
/// Route change receipt body
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteChangeReceiptBody {
|
||||
pub route_id: String,
|
||||
pub operation: String,
|
||||
pub source: String,
|
||||
pub destination: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
/// Capability grant receipt body
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapabilityGrantReceiptBody {
|
||||
pub capability_id: String,
|
||||
pub node_id: String,
|
||||
pub capability: String,
|
||||
pub scope: CapabilityScope,
|
||||
pub granted_by: String,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Capability revoke receipt body
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapabilityRevokeReceiptBody {
|
||||
pub capability_id: String,
|
||||
pub node_id: String,
|
||||
pub capability: String,
|
||||
pub revoked_by: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Topology snapshot receipt body
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopologySnapshotReceiptBody {
|
||||
pub snapshot_id: String,
|
||||
pub node_count: usize,
|
||||
pub route_count: usize,
|
||||
pub capability_count: usize,
|
||||
pub topology_hash: String,
|
||||
}
|
||||
|
||||
/// Mesh engine errors
|
||||
#[derive(Debug)]
|
||||
pub enum MeshError {
|
||||
NodeExists(String),
|
||||
NodeNotFound(String),
|
||||
RouteExists(String),
|
||||
RouteNotFound(String),
|
||||
CapabilityExists(String),
|
||||
CapabilityNotFound(String),
|
||||
IoError(std::io::Error),
|
||||
SerializationError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MeshError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MeshError::NodeExists(id) => write!(f, "Node already exists: {}", id),
|
||||
MeshError::NodeNotFound(id) => write!(f, "Node not found: {}", id),
|
||||
MeshError::RouteExists(id) => write!(f, "Route already exists: {}", id),
|
||||
MeshError::RouteNotFound(id) => write!(f, "Route not found: {}", id),
|
||||
MeshError::CapabilityExists(id) => write!(f, "Capability already exists: {}", id),
|
||||
MeshError::CapabilityNotFound(id) => write!(f, "Capability not found: {}", id),
|
||||
MeshError::IoError(e) => write!(f, "IO error: {}", e),
|
||||
MeshError::SerializationError(e) => write!(f, "Serialization error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MeshError {}
|
||||
|
||||
impl From<std::io::Error> for MeshError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
MeshError::IoError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for MeshError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
MeshError::SerializationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh engine for federation topology management
|
||||
pub struct MeshEngine {
|
||||
/// Path to VaultMesh root
|
||||
pub vaultmesh_root: PathBuf,
|
||||
/// In-memory node registry
|
||||
nodes: HashMap<String, Node>,
|
||||
/// In-memory route registry
|
||||
routes: HashMap<String, Route>,
|
||||
/// In-memory capability registry
|
||||
capabilities: HashMap<String, Capability>,
|
||||
/// Optional observability engine for metrics
|
||||
#[cfg(feature = "metrics")]
|
||||
pub observability: Option<Arc<ObservabilityEngine>>,
|
||||
}
|
||||
|
||||
impl MeshEngine {
|
||||
/// Create a new Mesh engine
|
||||
pub fn new(vaultmesh_root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
vaultmesh_root: vaultmesh_root.as_ref().to_path_buf(),
|
||||
nodes: HashMap::new(),
|
||||
routes: HashMap::new(),
|
||||
capabilities: HashMap::new(),
|
||||
#[cfg(feature = "metrics")]
|
||||
observability: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the observability engine for metrics
|
||||
#[cfg(feature = "metrics")]
|
||||
pub fn with_observability(mut self, obs: Arc<ObservabilityEngine>) -> Self {
|
||||
self.observability = Some(obs);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get path to mesh events JSONL
|
||||
fn events_path(&self) -> PathBuf {
|
||||
self.vaultmesh_root.join(Scroll::Mesh.jsonl_path())
|
||||
}
|
||||
|
||||
/// Register a new node in the mesh
|
||||
pub fn node_join(
|
||||
&mut self,
|
||||
node_id: &str,
|
||||
display_name: &str,
|
||||
node_type: NodeType,
|
||||
endpoints: HashMap<String, String>,
|
||||
joined_by: &str,
|
||||
) -> Result<Node, MeshError> {
|
||||
if self.nodes.contains_key(node_id) {
|
||||
return Err(MeshError::NodeExists(node_id.to_string()));
|
||||
}
|
||||
|
||||
let node = Node {
|
||||
node_id: node_id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
node_type: node_type.clone(),
|
||||
endpoints,
|
||||
public_key: None,
|
||||
capabilities: Vec::new(),
|
||||
status: NodeStatus::Active,
|
||||
joined_at: Utc::now(),
|
||||
tags: vec!["mesh".to_string(), "node".to_string()],
|
||||
};
|
||||
|
||||
self.nodes.insert(node_id.to_string(), node.clone());
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = NodeJoinReceiptBody {
|
||||
node_id: node_id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
node_type,
|
||||
joined_by: joined_by.to_string(),
|
||||
};
|
||||
|
||||
self.emit_receipt("mesh_node_join", &receipt_body, vec![
|
||||
"mesh".to_string(),
|
||||
"node".to_string(),
|
||||
"join".to_string(),
|
||||
node_id.to_string(),
|
||||
])?;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Remove a node from the mesh
|
||||
pub fn node_leave(
|
||||
&mut self,
|
||||
node_id: &str,
|
||||
reason: &str,
|
||||
left_by: &str,
|
||||
) -> Result<(), MeshError> {
|
||||
if !self.nodes.contains_key(node_id) {
|
||||
return Err(MeshError::NodeNotFound(node_id.to_string()));
|
||||
}
|
||||
|
||||
self.nodes.remove(node_id);
|
||||
|
||||
// Remove associated routes
|
||||
self.routes.retain(|_, r| r.source != node_id && r.destination != node_id);
|
||||
|
||||
// Remove associated capabilities
|
||||
self.capabilities.retain(|_, c| c.node_id != node_id);
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = NodeLeaveReceiptBody {
|
||||
node_id: node_id.to_string(),
|
||||
reason: reason.to_string(),
|
||||
left_by: left_by.to_string(),
|
||||
};
|
||||
|
||||
self.emit_receipt("mesh_node_leave", &receipt_body, vec![
|
||||
"mesh".to_string(),
|
||||
"node".to_string(),
|
||||
"leave".to_string(),
|
||||
node_id.to_string(),
|
||||
])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a route between nodes
|
||||
pub fn route_add(
|
||||
&mut self,
|
||||
source: &str,
|
||||
destination: &str,
|
||||
transport: &str,
|
||||
) -> Result<Route, MeshError> {
|
||||
let route_id = format!("route-{}-to-{}", source.split(':').last().unwrap_or(source),
|
||||
destination.split(':').last().unwrap_or(destination));
|
||||
|
||||
if self.routes.contains_key(&route_id) {
|
||||
return Err(MeshError::RouteExists(route_id));
|
||||
}
|
||||
|
||||
let route = Route {
|
||||
route_id: route_id.clone(),
|
||||
source: source.to_string(),
|
||||
destination: destination.to_string(),
|
||||
transport: transport.to_string(),
|
||||
priority: 1,
|
||||
status: RouteStatus::Active,
|
||||
latency_ms: None,
|
||||
established_at: Utc::now(),
|
||||
};
|
||||
|
||||
self.routes.insert(route_id.clone(), route.clone());
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = RouteChangeReceiptBody {
|
||||
route_id: route_id.clone(),
|
||||
operation: "add".to_string(),
|
||||
source: source.to_string(),
|
||||
destination: destination.to_string(),
|
||||
transport: transport.to_string(),
|
||||
};
|
||||
|
||||
self.emit_receipt("mesh_route_change", &receipt_body, vec![
|
||||
"mesh".to_string(),
|
||||
"route".to_string(),
|
||||
"add".to_string(),
|
||||
])?;
|
||||
|
||||
Ok(route)
|
||||
}
|
||||
|
||||
/// Remove a route
|
||||
pub fn route_remove(&mut self, route_id: &str) -> Result<(), MeshError> {
|
||||
let route = self.routes.remove(route_id)
|
||||
.ok_or_else(|| MeshError::RouteNotFound(route_id.to_string()))?;
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = RouteChangeReceiptBody {
|
||||
route_id: route_id.to_string(),
|
||||
operation: "remove".to_string(),
|
||||
source: route.source,
|
||||
destination: route.destination,
|
||||
transport: route.transport,
|
||||
};
|
||||
|
||||
self.emit_receipt("mesh_route_change", &receipt_body, vec![
|
||||
"mesh".to_string(),
|
||||
"route".to_string(),
|
||||
"remove".to_string(),
|
||||
])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grant a capability to a node
|
||||
pub fn capability_grant(
|
||||
&mut self,
|
||||
node_id: &str,
|
||||
capability: &str,
|
||||
scope: CapabilityScope,
|
||||
granted_by: &str,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Capability, MeshError> {
|
||||
let capability_id = format!("cap:{}:{}:{}",
|
||||
node_id.split(':').last().unwrap_or(node_id),
|
||||
capability,
|
||||
Utc::now().format("%Y"));
|
||||
|
||||
if self.capabilities.contains_key(&capability_id) {
|
||||
return Err(MeshError::CapabilityExists(capability_id));
|
||||
}
|
||||
|
||||
let cap = Capability {
|
||||
capability_id: capability_id.clone(),
|
||||
node_id: node_id.to_string(),
|
||||
capability: capability.to_string(),
|
||||
scope: scope.clone(),
|
||||
granted_by: granted_by.to_string(),
|
||||
granted_at: Utc::now(),
|
||||
expires_at,
|
||||
};
|
||||
|
||||
self.capabilities.insert(capability_id.clone(), cap.clone());
|
||||
|
||||
// Update node capabilities list
|
||||
if let Some(node) = self.nodes.get_mut(node_id) {
|
||||
if !node.capabilities.contains(&capability.to_string()) {
|
||||
node.capabilities.push(capability.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = CapabilityGrantReceiptBody {
|
||||
capability_id: capability_id.clone(),
|
||||
node_id: node_id.to_string(),
|
||||
capability: capability.to_string(),
|
||||
scope,
|
||||
granted_by: granted_by.to_string(),
|
||||
expires_at,
|
||||
};
|
||||
|
||||
self.emit_receipt("mesh_capability_grant", &receipt_body, vec![
|
||||
"mesh".to_string(),
|
||||
"capability".to_string(),
|
||||
"grant".to_string(),
|
||||
capability.to_string(),
|
||||
])?;
|
||||
|
||||
Ok(cap)
|
||||
}
|
||||
|
||||
/// Revoke a capability from a node
|
||||
pub fn capability_revoke(
|
||||
&mut self,
|
||||
node_id: &str,
|
||||
capability: &str,
|
||||
revoked_by: &str,
|
||||
reason: &str,
|
||||
) -> Result<(), MeshError> {
|
||||
// Find and remove the capability
|
||||
let cap_to_remove: Option<String> = self.capabilities.iter()
|
||||
.find(|(_, c)| c.node_id == node_id && c.capability == capability)
|
||||
.map(|(id, _)| id.clone());
|
||||
|
||||
let capability_id = cap_to_remove
|
||||
.ok_or_else(|| MeshError::CapabilityNotFound(
|
||||
format!("{}:{}", node_id, capability)
|
||||
))?;
|
||||
|
||||
self.capabilities.remove(&capability_id);
|
||||
|
||||
// Update node capabilities list
|
||||
if let Some(node) = self.nodes.get_mut(node_id) {
|
||||
node.capabilities.retain(|c| c != capability);
|
||||
}
|
||||
|
||||
// Emit receipt
|
||||
let receipt_body = CapabilityRevokeReceiptBody {
|
||||
capability_id,
|
||||
node_id: node_id.to_string(),
|
||||
capability: capability.to_string(),
|
||||
revoked_by: revoked_by.to_string(),
|
||||
reason: reason.to_string(),
|
||||
};
|
||||
|
||||
self.emit_receipt("mesh_capability_revoke", &receipt_body, vec![
|
||||
"mesh".to_string(),
|
||||
"capability".to_string(),
|
||||
"revoke".to_string(),
|
||||
capability.to_string(),
|
||||
])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a topology snapshot
|
||||
pub fn topology_snapshot(&self) -> Result<TopologySnapshot, MeshError> {
|
||||
let now = Utc::now();
|
||||
let snapshot_id = format!("snapshot-{}", now.format("%Y%m%d%H%M%S"));
|
||||
|
||||
// Compute topology hash
|
||||
let topology_data = serde_json::json!({
|
||||
"nodes": self.nodes.keys().collect::<Vec<_>>(),
|
||||
"routes": self.routes.keys().collect::<Vec<_>>(),
|
||||
"capabilities": self.capabilities.keys().collect::<Vec<_>>(),
|
||||
});
|
||||
let topology_hash = VmHash::from_json(&topology_data)?;
|
||||
|
||||
let snapshot = TopologySnapshot {
|
||||
snapshot_id: snapshot_id.clone(),
|
||||
timestamp: now,
|
||||
node_count: self.nodes.len(),
|
||||
route_count: self.routes.len(),
|
||||
capability_count: self.capabilities.len(),
|
||||
nodes: self.nodes.keys().cloned().collect(),
|
||||
topology_hash: topology_hash.as_str().to_string(),
|
||||
};
|
||||
|
||||
// Emit receipt (use a separate method to avoid borrow issues)
|
||||
let receipt_body = TopologySnapshotReceiptBody {
|
||||
snapshot_id: snapshot.snapshot_id.clone(),
|
||||
node_count: snapshot.node_count,
|
||||
route_count: snapshot.route_count,
|
||||
capability_count: snapshot.capability_count,
|
||||
topology_hash: snapshot.topology_hash.clone(),
|
||||
};
|
||||
|
||||
self.emit_snapshot_receipt(&receipt_body)?;
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Get a node by ID
|
||||
pub fn get_node(&self, node_id: &str) -> Option<&Node> {
|
||||
self.nodes.get(node_id)
|
||||
}
|
||||
|
||||
/// List all nodes
|
||||
pub fn list_nodes(&self) -> Vec<&Node> {
|
||||
self.nodes.values().collect()
|
||||
}
|
||||
|
||||
/// Get a route by ID
|
||||
pub fn get_route(&self, route_id: &str) -> Option<&Route> {
|
||||
self.routes.get(route_id)
|
||||
}
|
||||
|
||||
/// List all routes
|
||||
pub fn list_routes(&self) -> Vec<&Route> {
|
||||
self.routes.values().collect()
|
||||
}
|
||||
|
||||
/// Check if node has capability
|
||||
pub fn has_capability(&self, node_id: &str, capability: &str) -> bool {
|
||||
self.capabilities.values()
|
||||
.any(|c| c.node_id == node_id && c.capability == capability)
|
||||
}
|
||||
|
||||
/// Emit a mesh receipt
|
||||
fn emit_receipt<T: Serialize>(
|
||||
&self,
|
||||
receipt_type: &str,
|
||||
body: &T,
|
||||
tags: Vec<String>,
|
||||
) -> Result<(), MeshError> {
|
||||
#[cfg(feature = "metrics")]
|
||||
let start = Instant::now();
|
||||
|
||||
let body_json = serde_json::to_value(body)?;
|
||||
let root_hash = VmHash::from_json(&body_json)?;
|
||||
|
||||
let receipt = MeshReceipt {
|
||||
schema_version: SCHEMA_VERSION.to_string(),
|
||||
receipt_type: receipt_type.to_string(),
|
||||
timestamp: Utc::now(),
|
||||
scroll: "mesh".to_string(),
|
||||
tags,
|
||||
root_hash: root_hash.as_str().to_string(),
|
||||
body: body_json,
|
||||
};
|
||||
|
||||
// Ensure directory exists
|
||||
let path = self.events_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Append to JSONL
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
|
||||
let json = serde_json::to_string(&receipt)?;
|
||||
writeln!(file, "{}", json)?;
|
||||
|
||||
// Update ROOT file
|
||||
self.update_root_file()?;
|
||||
|
||||
// Record metrics if observability is enabled
|
||||
#[cfg(feature = "metrics")]
|
||||
if let Some(ref obs) = self.observability {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
obs.observe_emitted("mesh", elapsed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit snapshot receipt (separate method to work with &self)
|
||||
fn emit_snapshot_receipt(&self, body: &TopologySnapshotReceiptBody) -> Result<(), MeshError> {
|
||||
#[cfg(feature = "metrics")]
|
||||
let start = Instant::now();
|
||||
|
||||
let body_json = serde_json::to_value(body)?;
|
||||
let root_hash = VmHash::from_json(&body_json)?;
|
||||
|
||||
let receipt = MeshReceipt {
|
||||
schema_version: SCHEMA_VERSION.to_string(),
|
||||
receipt_type: "mesh_topology_snapshot".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
scroll: "mesh".to_string(),
|
||||
tags: vec!["mesh".to_string(), "snapshot".to_string(), "topology".to_string()],
|
||||
root_hash: root_hash.as_str().to_string(),
|
||||
body: body_json,
|
||||
};
|
||||
|
||||
let path = self.events_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
|
||||
let json = serde_json::to_string(&receipt)?;
|
||||
writeln!(file, "{}", json)?;
|
||||
|
||||
self.update_root_file()?;
|
||||
|
||||
#[cfg(feature = "metrics")]
|
||||
if let Some(ref obs) = self.observability {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
obs.observe_emitted("mesh", elapsed);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update ROOT.mesh.txt with current Merkle root
|
||||
fn update_root_file(&self) -> Result<(), MeshError> {
|
||||
let events_path = self.events_path();
|
||||
if !events_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&events_path)?;
|
||||
let hashes: Vec<VmHash> = content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| VmHash::blake3(l.as_bytes()))
|
||||
.collect();
|
||||
|
||||
let root = vaultmesh_core::merkle_root(&hashes);
|
||||
let root_path = self.vaultmesh_root.join(Scroll::Mesh.root_file());
|
||||
fs::write(&root_path, root.as_str())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_test_env() -> (TempDir, MeshEngine) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let engine = MeshEngine::new(tmp.path());
|
||||
(tmp, engine)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_join_creates_receipt() {
|
||||
let (tmp, mut engine) = setup_test_env();
|
||||
|
||||
let mut endpoints = HashMap::new();
|
||||
endpoints.insert("portal".to_string(), "https://brick-01.local:8443".to_string());
|
||||
|
||||
let node = engine.node_join(
|
||||
"did:vm:node:brick-01",
|
||||
"BRICK-01 (Dublin)",
|
||||
NodeType::Infrastructure,
|
||||
endpoints,
|
||||
"did:vm:human:admin",
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(node.node_id, "did:vm:node:brick-01");
|
||||
assert_eq!(node.status, NodeStatus::Active);
|
||||
|
||||
// Verify receipt was written
|
||||
let events_path = tmp.path().join("receipts/mesh/mesh_events.jsonl");
|
||||
assert!(events_path.exists());
|
||||
|
||||
let content = fs::read_to_string(&events_path).unwrap();
|
||||
assert!(content.contains("mesh_node_join"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_leave_removes_node() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
engine.node_join(
|
||||
"did:vm:node:brick-01",
|
||||
"BRICK-01",
|
||||
NodeType::Infrastructure,
|
||||
HashMap::new(),
|
||||
"did:vm:human:admin",
|
||||
).unwrap();
|
||||
|
||||
assert!(engine.get_node("did:vm:node:brick-01").is_some());
|
||||
|
||||
engine.node_leave(
|
||||
"did:vm:node:brick-01",
|
||||
"decommissioned",
|
||||
"did:vm:human:admin",
|
||||
).unwrap();
|
||||
|
||||
assert!(engine.get_node("did:vm:node:brick-01").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_add_connects_nodes() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
let route = engine.route_add(
|
||||
"did:vm:node:brick-01",
|
||||
"did:vm:node:brick-02",
|
||||
"wireguard",
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(route.source, "did:vm:node:brick-01");
|
||||
assert_eq!(route.destination, "did:vm:node:brick-02");
|
||||
assert_eq!(route.transport, "wireguard");
|
||||
assert_eq!(route.status, RouteStatus::Active);
|
||||
|
||||
// Verify route is retrievable
|
||||
assert!(engine.get_route(&route.route_id).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capability_grant_and_check() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
engine.node_join(
|
||||
"did:vm:node:brick-01",
|
||||
"BRICK-01",
|
||||
NodeType::Infrastructure,
|
||||
HashMap::new(),
|
||||
"did:vm:human:admin",
|
||||
).unwrap();
|
||||
|
||||
let cap = engine.capability_grant(
|
||||
"did:vm:node:brick-01",
|
||||
"anchor",
|
||||
CapabilityScope::Global,
|
||||
"did:vm:human:admin",
|
||||
None,
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(cap.capability, "anchor");
|
||||
assert!(engine.has_capability("did:vm:node:brick-01", "anchor"));
|
||||
|
||||
// Revoke and verify
|
||||
engine.capability_revoke(
|
||||
"did:vm:node:brick-01",
|
||||
"anchor",
|
||||
"did:vm:human:admin",
|
||||
"security audit",
|
||||
).unwrap();
|
||||
|
||||
assert!(!engine.has_capability("did:vm:node:brick-01", "anchor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topology_snapshot() {
|
||||
let (_tmp, mut engine) = setup_test_env();
|
||||
|
||||
// Add some nodes and routes
|
||||
engine.node_join("did:vm:node:brick-01", "BRICK-01", NodeType::Infrastructure, HashMap::new(), "admin").unwrap();
|
||||
engine.node_join("did:vm:node:brick-02", "BRICK-02", NodeType::Infrastructure, HashMap::new(), "admin").unwrap();
|
||||
engine.route_add("did:vm:node:brick-01", "did:vm:node:brick-02", "wireguard").unwrap();
|
||||
|
||||
let snapshot = engine.topology_snapshot().unwrap();
|
||||
|
||||
assert_eq!(snapshot.node_count, 2);
|
||||
assert_eq!(snapshot.route_count, 1);
|
||||
assert!(snapshot.snapshot_id.starts_with("snapshot-"));
|
||||
assert!(!snapshot.topology_hash.is_empty());
|
||||
}
|
||||
}
|
||||
76
vaultmesh-mesh/tests/metrics_integration.rs
Normal file
76
vaultmesh-mesh/tests/metrics_integration.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Integration test: Mesh operations update observability metrics
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo test -p vaultmesh-mesh --features metrics --test metrics_integration
|
||||
//!
|
||||
#![cfg(feature = "metrics")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use vaultmesh_mesh::{MeshEngine, NodeType};
|
||||
use vaultmesh_observability::ObservabilityEngine;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mesh_operations_update_observability_metrics() {
|
||||
// Temporary dir for mesh persistence
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
|
||||
// Dynamic port allocation - bind to port 0 to get an available port
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind to port 0");
|
||||
let addr = listener.local_addr().expect("get local addr");
|
||||
drop(listener); // Release so ObservabilityEngine can bind
|
||||
|
||||
// Start observability engine on the dynamically assigned port
|
||||
let obs = Arc::new(ObservabilityEngine::new());
|
||||
obs.clone()
|
||||
.serve(&addr)
|
||||
.await
|
||||
.expect("observability serve failed");
|
||||
|
||||
// Small delay to allow server to bind and be ready
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Create MeshEngine and attach observability
|
||||
let mut mesh = MeshEngine::new(tmp.path())
|
||||
.with_observability(obs.clone());
|
||||
|
||||
// Perform a basic operation that emits a receipt
|
||||
mesh.node_join(
|
||||
"did:vm:node:test",
|
||||
"Test Node",
|
||||
NodeType::Infrastructure,
|
||||
HashMap::new(),
|
||||
"admin",
|
||||
)
|
||||
.expect("node_join");
|
||||
|
||||
// Wait a little to allow the receipt emission and metric recording
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Fetch metrics from observability server using dynamic address
|
||||
let metrics_url = format!("http://{}/metrics", addr);
|
||||
let resp = reqwest::get(&metrics_url)
|
||||
.await
|
||||
.expect("request failed");
|
||||
assert!(resp.status().is_success(), "metrics endpoint must return 200");
|
||||
|
||||
let body = resp.text().await.expect("read body");
|
||||
|
||||
// Basic asserts: receipts counter exists and mesh module is present
|
||||
assert!(
|
||||
body.contains("vaultmesh_receipts_total"),
|
||||
"metrics should expose receipts counter"
|
||||
);
|
||||
|
||||
// Assert mesh module appears in metrics
|
||||
assert!(
|
||||
body.contains("mesh"),
|
||||
"mesh module should appear in metrics"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user