366 lines
12 KiB
Rust
366 lines
12 KiB
Rust
//! ChainFire-backed node storage
|
|
//!
|
|
//! This module provides persistent storage for node configurations
|
|
//! using ChainFire as the backend.
|
|
|
|
use chainfire_client::Client as ChainFireClient;
|
|
use deployer_types::{EnrollmentRuleSpec, NodeClassSpec, NodeConfig, NodeInfo, NodePoolSpec};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::Serialize;
|
|
use thiserror::Error;
|
|
use tracing::{debug, error, warn};
|
|
|
|
use crate::cluster::ClusterNodeRecord;
|
|
|
|
/// Storage errors
|
|
#[derive(Error, Debug)]
|
|
pub enum StorageError {
|
|
#[error("ChainFire connection error: {0}")]
|
|
Connection(String),
|
|
#[error("Serialization error: {0}")]
|
|
Serialization(#[from] serde_json::Error),
|
|
#[error("ChainFire client error: {0}")]
|
|
Client(String),
|
|
#[error("Node mapping conflict: {0}")]
|
|
Conflict(String),
|
|
}
|
|
|
|
impl From<chainfire_client::ClientError> for StorageError {
|
|
fn from(e: chainfire_client::ClientError) -> Self {
|
|
StorageError::Client(e.to_string())
|
|
}
|
|
}
|
|
|
|
/// Node storage backed by ChainFire
|
|
pub struct NodeStorage {
|
|
client: ChainFireClient,
|
|
namespace: String,
|
|
}
|
|
|
|
impl NodeStorage {
|
|
/// Connect to ChainFire and create a new storage instance
|
|
pub async fn connect(endpoint: &str, namespace: &str) -> Result<Self, StorageError> {
|
|
debug!(endpoint = %endpoint, namespace = %namespace, "Connecting to ChainFire");
|
|
|
|
let client = ChainFireClient::connect(endpoint)
|
|
.await
|
|
.map_err(|e| StorageError::Connection(e.to_string()))?;
|
|
|
|
Ok(Self {
|
|
client,
|
|
namespace: namespace.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Key for node config by machine_id
|
|
fn config_key(&self, machine_id: &str) -> String {
|
|
format!("{}/nodes/config/{}", self.namespace, machine_id)
|
|
}
|
|
|
|
/// Key for node info by node_id
|
|
fn info_key(&self, node_id: &str) -> String {
|
|
format!("{}/nodes/info/{}", self.namespace, node_id)
|
|
}
|
|
|
|
fn cluster_node_key(&self, cluster_namespace: &str, cluster_id: &str, node_id: &str) -> String {
|
|
format!(
|
|
"{}/clusters/{}/nodes/{}",
|
|
cluster_namespace, cluster_id, node_id
|
|
)
|
|
}
|
|
|
|
fn cluster_nodes_prefix(&self, cluster_namespace: &str, cluster_id: &str) -> String {
|
|
format!("{}/clusters/{}/nodes/", cluster_namespace, cluster_id)
|
|
}
|
|
|
|
fn cluster_node_classes_prefix(&self, cluster_namespace: &str, cluster_id: &str) -> String {
|
|
format!(
|
|
"{}/clusters/{}/node-classes/",
|
|
cluster_namespace, cluster_id
|
|
)
|
|
}
|
|
|
|
fn cluster_pools_prefix(&self, cluster_namespace: &str, cluster_id: &str) -> String {
|
|
format!("{}/clusters/{}/pools/", cluster_namespace, cluster_id)
|
|
}
|
|
|
|
fn cluster_enrollment_rules_prefix(&self, cluster_namespace: &str, cluster_id: &str) -> String {
|
|
format!(
|
|
"{}/clusters/{}/enrollment-rules/",
|
|
cluster_namespace, cluster_id
|
|
)
|
|
}
|
|
|
|
async fn list_cluster_objects<T: DeserializeOwned>(
|
|
&mut self,
|
|
prefix: String,
|
|
) -> Result<Vec<T>, StorageError> {
|
|
let kvs = self.client.get_prefix(&prefix).await?;
|
|
let mut values = Vec::with_capacity(kvs.len());
|
|
for (_key, value) in kvs {
|
|
match serde_json::from_slice::<T>(&value) {
|
|
Ok(record) => values.push(record),
|
|
Err(e) => {
|
|
warn!(error = %e, prefix = %prefix, "Failed to decode cluster object");
|
|
}
|
|
}
|
|
}
|
|
Ok(values)
|
|
}
|
|
|
|
/// Register or update node config for a machine_id
|
|
pub async fn register_node(
|
|
&mut self,
|
|
machine_id: &str,
|
|
config: &NodeConfig,
|
|
) -> Result<(), StorageError> {
|
|
let config_key = self.config_key(machine_id);
|
|
let config_json = serde_json::to_vec(config)?;
|
|
|
|
if let Some(existing) = self.client.get(&config_key).await? {
|
|
let existing_config: NodeConfig = serde_json::from_slice(&existing)?;
|
|
if existing_config.assignment.node_id != config.assignment.node_id {
|
|
return Err(StorageError::Conflict(format!(
|
|
"machine_id {} already mapped to {}",
|
|
machine_id, existing_config.assignment.node_id
|
|
)));
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
machine_id = %machine_id,
|
|
node_id = %config.assignment.node_id,
|
|
key = %config_key,
|
|
"Registering node config in ChainFire"
|
|
);
|
|
|
|
self.client.put(&config_key, &config_json).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Lookup node config by machine_id
|
|
pub async fn get_node_config(
|
|
&mut self,
|
|
machine_id: &str,
|
|
) -> Result<Option<NodeConfig>, StorageError> {
|
|
let config_key = self.config_key(machine_id);
|
|
|
|
debug!(machine_id = %machine_id, key = %config_key, "Looking up node config");
|
|
|
|
// Get config
|
|
match self.client.get(&config_key).await? {
|
|
Some(bytes) => {
|
|
let config: NodeConfig = serde_json::from_slice(&bytes)?;
|
|
Ok(Some(config))
|
|
}
|
|
None => {
|
|
debug!(machine_id = %machine_id, "No config found");
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Store node info (runtime state)
|
|
pub async fn store_node_info(&mut self, node_info: &NodeInfo) -> Result<(), StorageError> {
|
|
let key = self.info_key(&node_info.id);
|
|
let json = serde_json::to_vec(node_info)?;
|
|
|
|
debug!(
|
|
node_id = %node_info.id,
|
|
key = %key,
|
|
"Storing node info in ChainFire"
|
|
);
|
|
|
|
self.client.put(&key, &json).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Store cluster node state under ultracloud/clusters/{cluster_id}/nodes/{node_id}
|
|
pub async fn store_cluster_node<T: Serialize>(
|
|
&mut self,
|
|
cluster_namespace: &str,
|
|
cluster_id: &str,
|
|
node_id: &str,
|
|
node: &T,
|
|
) -> Result<(), StorageError> {
|
|
let key = self.cluster_node_key(cluster_namespace, cluster_id, node_id);
|
|
let json = serde_json::to_vec(node)?;
|
|
debug!(
|
|
cluster_namespace = %cluster_namespace,
|
|
cluster_id = %cluster_id,
|
|
node_id = %node_id,
|
|
key = %key,
|
|
"Storing cluster node in ChainFire"
|
|
);
|
|
self.client.put(&key, &json).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// List cluster nodes under ultracloud/clusters/{cluster_id}/nodes/
|
|
pub async fn list_cluster_nodes(
|
|
&mut self,
|
|
cluster_namespace: &str,
|
|
cluster_id: &str,
|
|
) -> Result<Vec<ClusterNodeRecord>, StorageError> {
|
|
let prefix = self.cluster_nodes_prefix(cluster_namespace, cluster_id);
|
|
let kvs = self.client.get_prefix(&prefix).await?;
|
|
|
|
let mut nodes = Vec::with_capacity(kvs.len());
|
|
for (_key, value) in kvs {
|
|
match serde_json::from_slice::<ClusterNodeRecord>(&value) {
|
|
Ok(record) => nodes.push(record),
|
|
Err(e) => {
|
|
warn!(error = %e, "Failed to decode cluster node record");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(nodes)
|
|
}
|
|
|
|
pub async fn list_node_classes(
|
|
&mut self,
|
|
cluster_namespace: &str,
|
|
cluster_id: &str,
|
|
) -> Result<Vec<NodeClassSpec>, StorageError> {
|
|
self.list_cluster_objects(self.cluster_node_classes_prefix(cluster_namespace, cluster_id))
|
|
.await
|
|
}
|
|
|
|
pub async fn list_pools(
|
|
&mut self,
|
|
cluster_namespace: &str,
|
|
cluster_id: &str,
|
|
) -> Result<Vec<NodePoolSpec>, StorageError> {
|
|
self.list_cluster_objects(self.cluster_pools_prefix(cluster_namespace, cluster_id))
|
|
.await
|
|
}
|
|
|
|
pub async fn list_enrollment_rules(
|
|
&mut self,
|
|
cluster_namespace: &str,
|
|
cluster_id: &str,
|
|
) -> Result<Vec<EnrollmentRuleSpec>, StorageError> {
|
|
self.list_cluster_objects(
|
|
self.cluster_enrollment_rules_prefix(cluster_namespace, cluster_id),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Get node info by node_id
|
|
pub async fn get_node_info(&mut self, node_id: &str) -> Result<Option<NodeInfo>, StorageError> {
|
|
let key = self.info_key(node_id);
|
|
|
|
match self.client.get(&key).await? {
|
|
Some(bytes) => {
|
|
let info: NodeInfo = serde_json::from_slice(&bytes)?;
|
|
Ok(Some(info))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// List all registered nodes
|
|
pub async fn list_nodes(&mut self) -> Result<Vec<NodeInfo>, StorageError> {
|
|
let prefix = format!("{}/nodes/info/", self.namespace);
|
|
|
|
let kvs = self.client.get_prefix(&prefix).await?;
|
|
|
|
let mut nodes = Vec::with_capacity(kvs.len());
|
|
for (_, value) in kvs {
|
|
match serde_json::from_slice::<NodeInfo>(&value) {
|
|
Ok(info) => nodes.push(info),
|
|
Err(e) => {
|
|
error!(error = %e, "Failed to deserialize node info");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(nodes)
|
|
}
|
|
|
|
/// List all pre-registered machine configs (machine_id -> config)
|
|
pub async fn list_machine_configs(
|
|
&mut self,
|
|
) -> Result<Vec<(String, NodeConfig)>, StorageError> {
|
|
let config_prefix = format!("{}/nodes/config/", self.namespace);
|
|
let configs = self.client.get_prefix(&config_prefix).await?;
|
|
|
|
let mut results = Vec::new();
|
|
for (key, value) in configs {
|
|
let key_str = String::from_utf8_lossy(&key);
|
|
if let Some(machine_id) = key_str.strip_prefix(&config_prefix) {
|
|
if let Ok(config) = serde_json::from_slice::<NodeConfig>(&value) {
|
|
results.push((machine_id.to_string(), config));
|
|
} else {
|
|
warn!(key = %key_str, "Failed to deserialize node config");
|
|
}
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use deployer_types::{BootstrapPlan, BootstrapSecrets, NodeAssignment};
|
|
|
|
// Note: Integration tests require a running ChainFire instance.
|
|
// These unit tests verify serialization and key generation.
|
|
|
|
#[test]
|
|
fn test_key_generation() {
|
|
// Can't test connect without ChainFire, but we can verify key format
|
|
let namespace = "deployer";
|
|
let machine_id = "abc123";
|
|
let node_id = "node01";
|
|
|
|
let config_key = format!("{}/nodes/config/{}", namespace, machine_id);
|
|
let info_key = format!("{}/nodes/info/{}", namespace, node_id);
|
|
|
|
assert_eq!(config_key, "deployer/nodes/config/abc123");
|
|
assert_eq!(info_key, "deployer/nodes/info/node01");
|
|
|
|
let cluster_namespace = "ultracloud";
|
|
let cluster_id = "cluster-a";
|
|
let cluster_key = format!(
|
|
"{}/clusters/{}/nodes/{}",
|
|
cluster_namespace, cluster_id, node_id
|
|
);
|
|
assert_eq!(cluster_key, "ultracloud/clusters/cluster-a/nodes/node01");
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_config_serialization() {
|
|
let config = NodeConfig::from_parts(
|
|
NodeAssignment {
|
|
node_id: "node01".to_string(),
|
|
hostname: "node01".to_string(),
|
|
role: "control-plane".to_string(),
|
|
ip: "10.0.1.10".to_string(),
|
|
labels: std::collections::HashMap::new(),
|
|
pool: None,
|
|
node_class: None,
|
|
failure_domain: None,
|
|
},
|
|
BootstrapPlan {
|
|
services: vec!["chainfire".to_string(), "flaredb".to_string()],
|
|
nix_profile: None,
|
|
install_plan: None,
|
|
},
|
|
BootstrapSecrets::default(),
|
|
);
|
|
|
|
let json = serde_json::to_vec(&config).unwrap();
|
|
let deserialized: NodeConfig = serde_json::from_slice(&json).unwrap();
|
|
|
|
assert_eq!(deserialized.assignment.hostname, "node01");
|
|
assert_eq!(deserialized.assignment.role, "control-plane");
|
|
assert_eq!(deserialized.bootstrap_plan.services.len(), 2);
|
|
assert!(deserialized
|
|
.bootstrap_secrets
|
|
.ssh_authorized_keys
|
|
.is_empty());
|
|
}
|
|
}
|