1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub enum NodeRole {
22 Primary,
24 Replica,
26 Relay,
28 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub enum ShardStrategy {
68 ConsistentHash,
70 RangePrefix,
72 RoundRobin,
74 DomainAffinity,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub enum ReplicationMode {
81 Synchronous,
83 Asynchronous,
85 SemiSync,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ClusterNode {
92 pub node_id: String,
94 pub name: String,
96 pub endpoint: String,
98 pub region: String,
100 pub role: NodeRole,
102 pub health: HealthStatus,
104 pub shard_ranges: Vec<String>,
106 pub last_heartbeat: String,
108 pub capacity: NodeCapacity,
110 pub registered_at: String,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct NodeCapacity {
117 pub storage_total: u64,
119 pub storage_used: u64,
121 pub object_count: u64,
123 pub max_connections: u32,
125 pub active_connections: u32,
127 pub cpu_utilization: f64,
129 pub memory_utilization: f64,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct ClusterConfig {
136 pub replication_factor: u32,
138 pub replication_mode: ReplicationMode,
140 pub shard_strategy: ShardStrategy,
142 pub shard_count: u32,
144 pub connection_pool_size: u32,
146 pub heartbeat_interval_secs: u32,
148 pub node_timeout_secs: u32,
150 pub metrics_enabled: bool,
152 pub metrics_port: u16,
154 pub chunk_threshold: u64,
156 pub chunk_size: u64,
158 pub transfer_compression: bool,
160 pub read_load_balance: bool,
162 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#[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
197fn 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
260fn 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
288fn 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 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
344pub 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
387pub 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#[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
502pub 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
549pub fn execute_metrics() -> Result<DatacenterResponse, LitError> {
551 let repo_root = find_repo_root()?;
552 let metrics = collect_metrics(&repo_root);
553
554 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 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
610pub 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}