Includes all pending changes needed for nixos-anywhere: - fiberlb: L7 policy, rule, certificate types - deployer: New service for cluster management - nix-nos: Generic network modules - Various service updates and fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
333 lines
9.4 KiB
Rust
333 lines
9.4 KiB
Rust
//! Node metadata helpers for Chainfire KVS
|
|
//!
|
|
//! This module provides helpers for storing and retrieving node metadata
|
|
//! in the Chainfire distributed KVS.
|
|
//!
|
|
//! # KVS Key Schema
|
|
//!
|
|
//! Node metadata is stored with the following key structure:
|
|
//! - `/nodes/<id>/info` - JSON-encoded NodeMetadata
|
|
//! - `/nodes/<id>/roles` - JSON-encoded roles (raft_role, gossip_role)
|
|
//! - `/nodes/<id>/capacity/cpu` - CPU cores (u32)
|
|
//! - `/nodes/<id>/capacity/memory_gb` - Memory in GB (u32)
|
|
//! - `/nodes/<id>/labels/<key>` - Custom labels (string)
|
|
//! - `/nodes/<id>/api_addr` - API address (string)
|
|
|
|
use crate::error::Result;
|
|
use crate::Client;
|
|
use chainfire_types::node::NodeRole;
|
|
use chainfire_types::RaftRole;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
/// Node metadata stored in KVS
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NodeMetadata {
|
|
/// Unique node identifier
|
|
pub id: u64,
|
|
/// Human-readable node name
|
|
pub name: String,
|
|
/// Raft participation role
|
|
pub raft_role: RaftRole,
|
|
/// Gossip/cluster role
|
|
pub gossip_role: NodeRole,
|
|
/// API address for client connections
|
|
pub api_addr: String,
|
|
/// Raft address for inter-node communication (optional for workers)
|
|
pub raft_addr: Option<String>,
|
|
/// Gossip address for membership protocol
|
|
pub gossip_addr: String,
|
|
/// Node capacity information
|
|
pub capacity: NodeCapacity,
|
|
/// Custom labels for node selection
|
|
pub labels: HashMap<String, String>,
|
|
}
|
|
|
|
/// Node capacity information
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct NodeCapacity {
|
|
/// Number of CPU cores
|
|
pub cpu_cores: u32,
|
|
/// Memory in gigabytes
|
|
pub memory_gb: u32,
|
|
/// Disk space in gigabytes (optional)
|
|
pub disk_gb: Option<u32>,
|
|
}
|
|
|
|
/// Filter for listing nodes
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct NodeFilter {
|
|
/// Filter by Raft role
|
|
pub raft_role: Option<RaftRole>,
|
|
/// Filter by gossip role
|
|
pub gossip_role: Option<NodeRole>,
|
|
/// Filter by labels (all must match)
|
|
pub labels: HashMap<String, String>,
|
|
}
|
|
|
|
impl NodeMetadata {
|
|
/// Create a new NodeMetadata for a control-plane node
|
|
pub fn control_plane(
|
|
id: u64,
|
|
name: impl Into<String>,
|
|
api_addr: impl Into<String>,
|
|
raft_addr: impl Into<String>,
|
|
gossip_addr: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
name: name.into(),
|
|
raft_role: RaftRole::Voter,
|
|
gossip_role: NodeRole::ControlPlane,
|
|
api_addr: api_addr.into(),
|
|
raft_addr: Some(raft_addr.into()),
|
|
gossip_addr: gossip_addr.into(),
|
|
capacity: NodeCapacity::default(),
|
|
labels: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Create a new NodeMetadata for a worker node
|
|
pub fn worker(
|
|
id: u64,
|
|
name: impl Into<String>,
|
|
api_addr: impl Into<String>,
|
|
gossip_addr: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
name: name.into(),
|
|
raft_role: RaftRole::None,
|
|
gossip_role: NodeRole::Worker,
|
|
api_addr: api_addr.into(),
|
|
raft_addr: None,
|
|
gossip_addr: gossip_addr.into(),
|
|
capacity: NodeCapacity::default(),
|
|
labels: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Set capacity information
|
|
pub fn with_capacity(mut self, cpu_cores: u32, memory_gb: u32) -> Self {
|
|
self.capacity.cpu_cores = cpu_cores;
|
|
self.capacity.memory_gb = memory_gb;
|
|
self
|
|
}
|
|
|
|
/// Add a label
|
|
pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
|
self.labels.insert(key.into(), value.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Key prefix for all node metadata
|
|
const NODE_PREFIX: &str = "/nodes/";
|
|
|
|
/// Generate the key for node info
|
|
fn node_info_key(id: u64) -> String {
|
|
format!("{}{}/info", NODE_PREFIX, id)
|
|
}
|
|
|
|
/// Generate the key for a node label
|
|
fn node_label_key(id: u64, label: &str) -> String {
|
|
format!("{}{}/labels/{}", NODE_PREFIX, id, label)
|
|
}
|
|
|
|
/// Register a node in the cluster by storing its metadata in KVS
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `client` - The Chainfire client
|
|
/// * `meta` - Node metadata to register
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The revision number of the write operation
|
|
pub async fn register_node(client: &mut Client, meta: &NodeMetadata) -> Result<u64> {
|
|
let key = node_info_key(meta.id);
|
|
let value = serde_json::to_string(meta)
|
|
.map_err(|e| crate::error::ClientError::Internal(e.to_string()))?;
|
|
|
|
client.put_str(&key, &value).await
|
|
}
|
|
|
|
/// Update a specific node attribute
|
|
pub async fn update_node_label(
|
|
client: &mut Client,
|
|
node_id: u64,
|
|
label: &str,
|
|
value: &str,
|
|
) -> Result<u64> {
|
|
let key = node_label_key(node_id, label);
|
|
client.put_str(&key, value).await
|
|
}
|
|
|
|
/// Get a node's metadata by ID
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `client` - The Chainfire client
|
|
/// * `node_id` - The node ID to look up
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The node metadata if found, None otherwise
|
|
pub async fn get_node(client: &mut Client, node_id: u64) -> Result<Option<NodeMetadata>> {
|
|
let key = node_info_key(node_id);
|
|
let value = client.get_str(&key).await?;
|
|
|
|
match value {
|
|
Some(json) => {
|
|
let meta: NodeMetadata = serde_json::from_str(&json)
|
|
.map_err(|e| crate::error::ClientError::Internal(e.to_string()))?;
|
|
Ok(Some(meta))
|
|
}
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// List all registered nodes
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `client` - The Chainfire client
|
|
/// * `filter` - Optional filter criteria
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A list of node metadata matching the filter
|
|
pub async fn list_nodes(client: &mut Client, filter: &NodeFilter) -> Result<Vec<NodeMetadata>> {
|
|
let prefix = NODE_PREFIX.to_string();
|
|
let entries = client.get_prefix(&prefix).await?;
|
|
|
|
let mut nodes = Vec::new();
|
|
|
|
for (key, value) in entries {
|
|
let key_str = String::from_utf8_lossy(&key);
|
|
|
|
// Only process /nodes/<id>/info keys
|
|
if !key_str.ends_with("/info") {
|
|
continue;
|
|
}
|
|
|
|
let json = String::from_utf8_lossy(&value);
|
|
if let Ok(meta) = serde_json::from_str::<NodeMetadata>(&json) {
|
|
// Apply filters
|
|
if let Some(ref raft_role) = filter.raft_role {
|
|
if meta.raft_role != *raft_role {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if let Some(ref gossip_role) = filter.gossip_role {
|
|
if meta.gossip_role != *gossip_role {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check label filters
|
|
let mut labels_match = true;
|
|
for (k, v) in &filter.labels {
|
|
match meta.labels.get(k) {
|
|
Some(node_v) if node_v == v => {}
|
|
_ => {
|
|
labels_match = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if labels_match {
|
|
nodes.push(meta);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by node ID for consistent ordering
|
|
nodes.sort_by_key(|n| n.id);
|
|
|
|
Ok(nodes)
|
|
}
|
|
|
|
/// Unregister a node from the cluster
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `client` - The Chainfire client
|
|
/// * `node_id` - The node ID to unregister
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// True if the node was found and deleted
|
|
pub async fn unregister_node(client: &mut Client, node_id: u64) -> Result<bool> {
|
|
let key = node_info_key(node_id);
|
|
client.delete(&key).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_node_info_key() {
|
|
assert_eq!(node_info_key(1), "/nodes/1/info");
|
|
assert_eq!(node_info_key(123), "/nodes/123/info");
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_label_key() {
|
|
assert_eq!(node_label_key(1, "zone"), "/nodes/1/labels/zone");
|
|
}
|
|
|
|
#[test]
|
|
fn test_control_plane_metadata() {
|
|
let meta = NodeMetadata::control_plane(
|
|
1,
|
|
"cp-1",
|
|
"127.0.0.1:2379",
|
|
"127.0.0.1:2380",
|
|
"127.0.0.1:2381",
|
|
);
|
|
|
|
assert_eq!(meta.id, 1);
|
|
assert_eq!(meta.raft_role, RaftRole::Voter);
|
|
assert_eq!(meta.gossip_role, NodeRole::ControlPlane);
|
|
assert!(meta.raft_addr.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_worker_metadata() {
|
|
let meta = NodeMetadata::worker(100, "worker-1", "127.0.0.1:3379", "127.0.0.1:3381");
|
|
|
|
assert_eq!(meta.id, 100);
|
|
assert_eq!(meta.raft_role, RaftRole::None);
|
|
assert_eq!(meta.gossip_role, NodeRole::Worker);
|
|
assert!(meta.raft_addr.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_with_capacity() {
|
|
let meta = NodeMetadata::worker(1, "worker", "addr", "gossip")
|
|
.with_capacity(8, 32)
|
|
.with_label("zone", "us-west-1");
|
|
|
|
assert_eq!(meta.capacity.cpu_cores, 8);
|
|
assert_eq!(meta.capacity.memory_gb, 32);
|
|
assert_eq!(meta.labels.get("zone"), Some(&"us-west-1".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_metadata_serialization() {
|
|
let meta = NodeMetadata::control_plane(1, "test", "api", "raft", "gossip")
|
|
.with_capacity(4, 16)
|
|
.with_label("env", "prod");
|
|
|
|
let json = serde_json::to_string(&meta).unwrap();
|
|
let deserialized: NodeMetadata = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(meta.id, deserialized.id);
|
|
assert_eq!(meta.raft_role, deserialized.raft_role);
|
|
assert_eq!(meta.capacity.cpu_cores, deserialized.capacity.cpu_cores);
|
|
}
|
|
}
|