Skip to main content

lit/commands/
datacenter.rs

1//! Datacenter deployment optimizations — cluster node management, object store
2//! sharding, replication factor control, health monitoring, Prometheus-style
3//! metrics, and connection pooling configuration.
4//!
5//! These features enable Lit to operate in distributed datacenter environments
6//! with high availability, fault tolerance, and observability requirements.
7
8use crate::core::find_repo_root;
9use crate::errors::LitError;
10use crate::response::DatacenterResponse;
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fs;
15use std::path::Path;
16
17// ── Data types ──────────────────────────────────────────────────────────────
18
19/// Role a node plays in the cluster
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub enum NodeRole {
22    /// Full read-write primary
23    Primary,
24    /// Read replica
25    Replica,
26    /// Relay / edge cache only
27    Relay,
28    /// Metrics & monitoring observer
29    Observer,
30}
31
32impl std::fmt::Display for NodeRole {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            NodeRole::Primary => write!(f, "primary"),
36            NodeRole::Replica => write!(f, "replica"),
37            NodeRole::Relay => write!(f, "relay"),
38            NodeRole::Observer => write!(f, "observer"),
39        }
40    }
41}
42
43/// Health status of a node
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub enum HealthStatus {
46    Healthy,
47    Degraded,
48    Unreachable,
49    Draining,
50    Bootstrapping,
51}
52
53impl std::fmt::Display for HealthStatus {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            HealthStatus::Healthy => write!(f, "healthy"),
57            HealthStatus::Degraded => write!(f, "degraded"),
58            HealthStatus::Unreachable => write!(f, "unreachable"),
59            HealthStatus::Draining => write!(f, "draining"),
60            HealthStatus::Bootstrapping => write!(f, "bootstrapping"),
61        }
62    }
63}
64
65/// Sharding strategy for distributing objects across nodes
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub enum ShardStrategy {
68    /// Consistent hash ring (default)
69    ConsistentHash,
70    /// Range-based on object hash prefix
71    RangePrefix,
72    /// Round-robin object distribution
73    RoundRobin,
74    /// Domain-aware — keep a content domain's objects co-located
75    DomainAffinity,
76}
77
78/// Replication mode
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub enum ReplicationMode {
81    /// Synchronous — wait for all replicas to confirm
82    Synchronous,
83    /// Async — primary confirms immediately, replicas catch up
84    Asynchronous,
85    /// Semi-sync — wait for at least quorum to confirm
86    SemiSync,
87}
88
89/// A registered datacenter cluster node
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ClusterNode {
92    /// Unique node identifier
93    pub node_id: String,
94    /// Display name
95    pub name: String,
96    /// Network endpoint (host:port or URL)
97    pub endpoint: String,
98    /// Region / availability zone
99    pub region: String,
100    /// Node role
101    pub role: NodeRole,
102    /// Current health
103    pub health: HealthStatus,
104    /// Shard assignments (hex prefixes this node owns)
105    pub shard_ranges: Vec<String>,
106    /// Last heartbeat timestamp
107    pub last_heartbeat: String,
108    /// Node capacity metrics
109    pub capacity: NodeCapacity,
110    /// ISO 8601 date the node was registered
111    pub registered_at: String,
112}
113
114/// Capacity and utilization metrics for a node
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct NodeCapacity {
117    /// Total storage in bytes
118    pub storage_total: u64,
119    /// Used storage in bytes
120    pub storage_used: u64,
121    /// Object count on this node
122    pub object_count: u64,
123    /// Maximum concurrent connections
124    pub max_connections: u32,
125    /// Current active connections
126    pub active_connections: u32,
127    /// CPU utilization (0.0 – 1.0)
128    pub cpu_utilization: f64,
129    /// Memory utilization (0.0 – 1.0)
130    pub memory_utilization: f64,
131}
132
133/// Cluster-level configuration
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ClusterConfig {
136    /// Replication factor (how many copies per object)
137    pub replication_factor: u32,
138    /// Replication mode
139    pub replication_mode: ReplicationMode,
140    /// Sharding strategy
141    pub shard_strategy: ShardStrategy,
142    /// Number of virtual shards (powers of 16, e.g. 256 = 2 hex chars)
143    pub shard_count: u32,
144    /// Connection pool size per node
145    pub connection_pool_size: u32,
146    /// Heartbeat interval in seconds
147    pub heartbeat_interval_secs: u32,
148    /// Node timeout before marking unreachable
149    pub node_timeout_secs: u32,
150    /// Whether to enable Prometheus-style metrics endpoint
151    pub metrics_enabled: bool,
152    /// Metrics endpoint port (default: 9090)
153    pub metrics_port: u16,
154    /// Maximum object size before chunked transfer (bytes)
155    pub chunk_threshold: u64,
156    /// Chunk size for large object transfer (bytes)
157    pub chunk_size: u64,
158    /// Compression for inter-node transfer
159    pub transfer_compression: bool,
160    /// Enable read replicas for load balancing reads
161    pub read_load_balance: bool,
162    /// Write concern — how many nodes must confirm a write
163    pub write_concern: u32,
164}
165
166impl Default for ClusterConfig {
167    fn default() -> Self {
168        Self {
169            replication_factor: 3,
170            replication_mode: ReplicationMode::SemiSync,
171            shard_strategy: ShardStrategy::ConsistentHash,
172            shard_count: 256,
173            connection_pool_size: 32,
174            heartbeat_interval_secs: 10,
175            node_timeout_secs: 30,
176            metrics_enabled: true,
177            metrics_port: 9090,
178            chunk_threshold: 64 * 1024 * 1024,
179            chunk_size: 4 * 1024 * 1024,
180            transfer_compression: true,
181            read_load_balance: true,
182            write_concern: 2,
183        }
184    }
185}
186
187/// Prometheus-style metric representation
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct Metric {
190    pub name: String,
191    pub help: String,
192    pub metric_type: String,
193    pub value: f64,
194    pub labels: HashMap<String, String>,
195}
196
197// ── Helpers ─────────────────────────────────────────────────────────────────
198
199fn datacenter_dir(repo_root: &Path) -> std::path::PathBuf {
200    repo_root.join(".lit").join("datacenter")
201}
202
203fn nodes_dir(repo_root: &Path) -> std::path::PathBuf {
204    datacenter_dir(repo_root).join("nodes")
205}
206
207fn load_cluster_config(repo_root: &Path) -> Result<ClusterConfig, LitError> {
208    let path = datacenter_dir(repo_root).join("cluster.json");
209    if path.exists() {
210        let json = fs::read_to_string(&path).map_err(|e| LitError::io(e.to_string()))?;
211        serde_json::from_str(&json)
212            .map_err(|e| LitError::general(format!("Parse cluster config: {}", e)))
213    } else {
214        Ok(ClusterConfig::default())
215    }
216}
217
218fn save_cluster_config(repo_root: &Path, config: &ClusterConfig) -> Result<(), LitError> {
219    let dir = datacenter_dir(repo_root);
220    fs::create_dir_all(&dir).map_err(|e| LitError::io(e.to_string()))?;
221    let json = serde_json::to_string_pretty(config)
222        .map_err(|e| LitError::general(format!("Serialize cluster config: {}", e)))?;
223    fs::write(dir.join("cluster.json"), json).map_err(|e| LitError::io(e.to_string()))?;
224    Ok(())
225}
226
227fn save_node(repo_root: &Path, node: &ClusterNode) -> Result<(), LitError> {
228    let dir = nodes_dir(repo_root);
229    fs::create_dir_all(&dir).map_err(|e| LitError::io(e.to_string()))?;
230    let json = serde_json::to_string_pretty(node)
231        .map_err(|e| LitError::general(format!("Serialize node: {}", e)))?;
232    fs::write(dir.join(format!("{}.json", node.node_id)), json)
233        .map_err(|e| LitError::io(e.to_string()))?;
234    Ok(())
235}
236
237fn load_all_nodes(repo_root: &Path) -> Result<Vec<ClusterNode>, LitError> {
238    let dir = nodes_dir(repo_root);
239    let mut nodes = Vec::new();
240    if dir.exists() {
241        for entry in fs::read_dir(&dir).map_err(|e| LitError::io(e.to_string()))? {
242            let entry = entry.map_err(|e| LitError::io(e.to_string()))?;
243            if entry
244                .path()
245                .extension()
246                .map(|e| e == "json")
247                .unwrap_or(false)
248            {
249                let json =
250                    fs::read_to_string(entry.path()).map_err(|e| LitError::io(e.to_string()))?;
251                if let Ok(node) = serde_json::from_str::<ClusterNode>(&json) {
252                    nodes.push(node);
253                }
254            }
255        }
256    }
257    Ok(nodes)
258}
259
260/// Assign shard ranges to a node based on current cluster state
261fn compute_shard_ranges(node_id: &str, all_nodes: &[ClusterNode], shard_count: u32) -> Vec<String> {
262    let active_nodes: Vec<&ClusterNode> = all_nodes
263        .iter()
264        .filter(|n| n.health != HealthStatus::Unreachable && n.health != HealthStatus::Draining)
265        .collect();
266
267    if active_nodes.is_empty() {
268        return (0..shard_count).map(|i| format!("{:02x}", i)).collect();
269    }
270
271    let pos = active_nodes
272        .iter()
273        .position(|n| n.node_id == node_id)
274        .unwrap_or(active_nodes.len());
275
276    let total = active_nodes.len() as u32;
277    let shards_per_node = shard_count / total.max(1);
278    let start = pos as u32 * shards_per_node;
279    let end = if pos as u32 == total - 1 {
280        shard_count
281    } else {
282        start + shards_per_node
283    };
284
285    (start..end).map(|i| format!("{:02x}", i % 256)).collect()
286}
287
288/// Collect Prometheus-style metrics for the local node
289fn collect_metrics(repo_root: &Path) -> Vec<Metric> {
290    let objects_dir = repo_root.join(".lit").join("objects");
291    let mut object_count: u64 = 0;
292    let mut total_size: u64 = 0;
293
294    if let Ok(entries) = fs::read_dir(&objects_dir) {
295        for shard in entries.flatten() {
296            if shard.file_type().map(|t| t.is_dir()).unwrap_or(false) {
297                if let Ok(files) = fs::read_dir(shard.path()) {
298                    for file in files.flatten() {
299                        object_count += 1;
300                        total_size += file.metadata().map(|m| m.len()).unwrap_or(0);
301                    }
302                }
303            }
304        }
305    }
306
307    // Counted through list_refs rather than by reading the directory: an
308    // encrypted repository keeps its refs in one index, so the directory is
309    // empty and a raw count would report zero.
310    let refs_count: u64 = ["heads", "tags", "remotes"]
311        .iter()
312        .map(|prefix| {
313            crate::core::list_refs(repo_root, prefix)
314                .map(|refs| refs.len() as u64)
315                .unwrap_or(0)
316        })
317        .sum();
318
319    vec![
320        Metric {
321            name: "lit_objects_total".into(),
322            help: "Total number of objects in the local store".into(),
323            metric_type: "gauge".into(),
324            value: object_count as f64,
325            labels: HashMap::new(),
326        },
327        Metric {
328            name: "lit_objects_size_bytes".into(),
329            help: "Total size of all objects in bytes".into(),
330            metric_type: "gauge".into(),
331            value: total_size as f64,
332            labels: HashMap::new(),
333        },
334        Metric {
335            name: "lit_refs_total".into(),
336            help: "Total number of refs".into(),
337            metric_type: "gauge".into(),
338            value: refs_count as f64,
339            labels: HashMap::new(),
340        },
341    ]
342}
343
344// ── Public API ──────────────────────────────────────────────────────────────
345
346/// Show cluster status — nodes, shard distribution, config
347pub fn execute_status() -> Result<DatacenterResponse, LitError> {
348    let repo_root = find_repo_root()?;
349    let config = load_cluster_config(&repo_root)?;
350    let nodes = load_all_nodes(&repo_root)?;
351
352    let healthy = nodes
353        .iter()
354        .filter(|n| n.health == HealthStatus::Healthy)
355        .count();
356    let total = nodes.len();
357
358    Ok(DatacenterResponse {
359        action: "status".into(),
360        message: format!(
361            "Cluster: {} node(s) ({} healthy), replication_factor={}, shards={}, strategy={}",
362            total,
363            healthy,
364            config.replication_factor,
365            config.shard_count,
366            match config.shard_strategy {
367                ShardStrategy::ConsistentHash => "consistent-hash",
368                ShardStrategy::RangePrefix => "range-prefix",
369                ShardStrategy::RoundRobin => "round-robin",
370                ShardStrategy::DomainAffinity => "domain-affinity",
371            }
372        ),
373        details: Some(serde_json::json!({
374            "config": config,
375            "nodes": nodes,
376            "summary": {
377                "total_nodes": total,
378                "healthy_nodes": healthy,
379                "total_storage": nodes.iter().map(|n| n.capacity.storage_total).sum::<u64>(),
380                "used_storage": nodes.iter().map(|n| n.capacity.storage_used).sum::<u64>(),
381                "total_objects": nodes.iter().map(|n| n.capacity.object_count).sum::<u64>(),
382            }
383        })),
384    })
385}
386
387/// Register a new cluster node
388pub fn execute_register_node(
389    node_id: String,
390    name: String,
391    endpoint: String,
392    region: String,
393    role: Option<String>,
394) -> Result<DatacenterResponse, LitError> {
395    let repo_root = find_repo_root()?;
396    let config = load_cluster_config(&repo_root)?;
397    let existing = load_all_nodes(&repo_root)?;
398
399    let role_enum = match role.as_deref() {
400        Some("primary") => NodeRole::Primary,
401        Some("replica") => NodeRole::Replica,
402        Some("relay") => NodeRole::Relay,
403        Some("observer") => NodeRole::Observer,
404        _ => NodeRole::Replica,
405    };
406
407    let node = ClusterNode {
408        node_id: node_id.clone(),
409        name: name.clone(),
410        endpoint,
411        region: region.clone(),
412        role: role_enum,
413        health: HealthStatus::Bootstrapping,
414        shard_ranges: compute_shard_ranges(&node_id, &existing, config.shard_count),
415        last_heartbeat: Utc::now().to_rfc3339(),
416        capacity: NodeCapacity {
417            storage_total: 0,
418            storage_used: 0,
419            object_count: 0,
420            max_connections: config.connection_pool_size,
421            active_connections: 0,
422            cpu_utilization: 0.0,
423            memory_utilization: 0.0,
424        },
425        registered_at: Utc::now().to_rfc3339(),
426    };
427
428    save_node(&repo_root, &node)?;
429
430    Ok(DatacenterResponse {
431        action: "register-node".into(),
432        message: format!(
433            "Node '{}' ({}) registered in region '{}' with {} shard(s)",
434            name,
435            node_id,
436            region,
437            node.shard_ranges.len()
438        ),
439        details: Some(serde_json::to_value(&node).unwrap_or_default()),
440    })
441}
442
443/// Configure cluster-level settings
444#[allow(clippy::too_many_arguments)]
445pub fn execute_configure(
446    replication_factor: Option<u32>,
447    shard_count: Option<u32>,
448    shard_strategy: Option<String>,
449    replication_mode: Option<String>,
450    connection_pool_size: Option<u32>,
451    metrics_enabled: Option<bool>,
452    metrics_port: Option<u16>,
453    write_concern: Option<u32>,
454) -> Result<DatacenterResponse, LitError> {
455    let repo_root = find_repo_root()?;
456    let mut config = load_cluster_config(&repo_root)?;
457
458    if let Some(rf) = replication_factor {
459        config.replication_factor = rf;
460    }
461    if let Some(sc) = shard_count {
462        config.shard_count = sc;
463    }
464    if let Some(ss) = shard_strategy {
465        config.shard_strategy = match ss.as_str() {
466            "consistent-hash" => ShardStrategy::ConsistentHash,
467            "range-prefix" => ShardStrategy::RangePrefix,
468            "round-robin" => ShardStrategy::RoundRobin,
469            "domain-affinity" => ShardStrategy::DomainAffinity,
470            _ => ShardStrategy::ConsistentHash,
471        };
472    }
473    if let Some(rm) = replication_mode {
474        config.replication_mode = match rm.as_str() {
475            "sync" | "synchronous" => ReplicationMode::Synchronous,
476            "async" | "asynchronous" => ReplicationMode::Asynchronous,
477            _ => ReplicationMode::SemiSync,
478        };
479    }
480    if let Some(cps) = connection_pool_size {
481        config.connection_pool_size = cps;
482    }
483    if let Some(me) = metrics_enabled {
484        config.metrics_enabled = me;
485    }
486    if let Some(mp) = metrics_port {
487        config.metrics_port = mp;
488    }
489    if let Some(wc) = write_concern {
490        config.write_concern = wc;
491    }
492
493    save_cluster_config(&repo_root, &config)?;
494
495    Ok(DatacenterResponse {
496        action: "configure".into(),
497        message: "Cluster configuration updated".into(),
498        details: Some(serde_json::to_value(&config).unwrap_or_default()),
499    })
500}
501
502/// Run health checks on all registered nodes
503pub fn execute_health() -> Result<DatacenterResponse, LitError> {
504    let repo_root = find_repo_root()?;
505    let config = load_cluster_config(&repo_root)?;
506    let nodes = load_all_nodes(&repo_root)?;
507
508    let mut health_report: Vec<serde_json::Value> = Vec::new();
509    let timeout_cutoff = Utc::now() - chrono::Duration::seconds(config.node_timeout_secs as i64);
510
511    for node in &nodes {
512        let last_hb = chrono::DateTime::parse_from_rfc3339(&node.last_heartbeat)
513            .map(|dt| dt.with_timezone(&Utc))
514            .unwrap_or_else(|_| Utc::now());
515
516        let effective_health = if last_hb < timeout_cutoff && node.health == HealthStatus::Healthy {
517            HealthStatus::Unreachable
518        } else {
519            node.health.clone()
520        };
521
522        let storage_pct = if node.capacity.storage_total > 0 {
523            (node.capacity.storage_used as f64 / node.capacity.storage_total as f64) * 100.0
524        } else {
525            0.0
526        };
527
528        health_report.push(serde_json::json!({
529            "node_id": node.node_id,
530            "name": node.name,
531            "role": node.role.to_string(),
532            "health": effective_health.to_string(),
533            "region": node.region,
534            "storage_pct": format!("{:.1}%", storage_pct),
535            "cpu": format!("{:.1}%", node.capacity.cpu_utilization * 100.0),
536            "memory": format!("{:.1}%", node.capacity.memory_utilization * 100.0),
537            "connections": format!("{}/{}", node.capacity.active_connections, node.capacity.max_connections),
538            "last_heartbeat": node.last_heartbeat,
539        }));
540    }
541
542    Ok(DatacenterResponse {
543        action: "health".into(),
544        message: format!("Health check for {} node(s)", nodes.len()),
545        details: Some(serde_json::to_value(&health_report).unwrap_or_default()),
546    })
547}
548
549/// Collect and return Prometheus-style metrics
550pub fn execute_metrics() -> Result<DatacenterResponse, LitError> {
551    let repo_root = find_repo_root()?;
552    let metrics = collect_metrics(&repo_root);
553
554    // Also include per-node metrics if cluster is configured
555    let nodes = load_all_nodes(&repo_root)?;
556    let mut all_metrics = metrics;
557
558    all_metrics.push(Metric {
559        name: "lit_cluster_nodes_total".into(),
560        help: "Total nodes in cluster".into(),
561        metric_type: "gauge".into(),
562        value: nodes.len() as f64,
563        labels: HashMap::new(),
564    });
565
566    let healthy = nodes
567        .iter()
568        .filter(|n| n.health == HealthStatus::Healthy)
569        .count();
570    all_metrics.push(Metric {
571        name: "lit_cluster_nodes_healthy".into(),
572        help: "Healthy nodes in cluster".into(),
573        metric_type: "gauge".into(),
574        value: healthy as f64,
575        labels: HashMap::new(),
576    });
577
578    // Render as Prometheus exposition format
579    let exposition: String = all_metrics
580        .iter()
581        .map(|m| {
582            let labels_str = if m.labels.is_empty() {
583                String::new()
584            } else {
585                let pairs: Vec<String> = m
586                    .labels
587                    .iter()
588                    .map(|(k, v)| format!("{}=\"{}\"", k, v))
589                    .collect();
590                format!("{{{}}}", pairs.join(","))
591            };
592            format!(
593                "# HELP {} {}\n# TYPE {} {}\n{}{} {}",
594                m.name, m.help, m.name, m.metric_type, m.name, labels_str, m.value
595            )
596        })
597        .collect::<Vec<_>>()
598        .join("\n\n");
599
600    Ok(DatacenterResponse {
601        action: "metrics".into(),
602        message: format!("{} metric(s) collected", all_metrics.len()),
603        details: Some(serde_json::json!({
604            "metrics": all_metrics,
605            "exposition": exposition,
606        })),
607    })
608}
609
610/// Remove a node from the cluster (drain first recommended)
611pub fn execute_remove_node(node_id: String) -> Result<DatacenterResponse, LitError> {
612    let repo_root = find_repo_root()?;
613    let dir = nodes_dir(&repo_root);
614    let path = dir.join(format!("{}.json", node_id));
615
616    if !path.exists() {
617        return Err(LitError::general(format!("Node not found: {}", node_id)));
618    }
619
620    fs::remove_file(&path).map_err(|e| LitError::io(e.to_string()))?;
621
622    Ok(DatacenterResponse {
623        action: "remove-node".into(),
624        message: format!("Node '{}' removed from cluster", node_id),
625        details: None,
626    })
627}