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 = repo_root
308 .join(".lit")
309 .join("refs")
310 .read_dir()
311 .map(|e| e.count() as u64)
312 .unwrap_or(0);
313
314 vec![
315 Metric {
316 name: "lit_objects_total".into(),
317 help: "Total number of objects in the local store".into(),
318 metric_type: "gauge".into(),
319 value: object_count as f64,
320 labels: HashMap::new(),
321 },
322 Metric {
323 name: "lit_objects_size_bytes".into(),
324 help: "Total size of all objects in bytes".into(),
325 metric_type: "gauge".into(),
326 value: total_size as f64,
327 labels: HashMap::new(),
328 },
329 Metric {
330 name: "lit_refs_total".into(),
331 help: "Total number of refs".into(),
332 metric_type: "gauge".into(),
333 value: refs_count as f64,
334 labels: HashMap::new(),
335 },
336 ]
337}
338
339pub fn execute_status() -> Result<DatacenterResponse, LitError> {
343 let repo_root = find_repo_root()?;
344 let config = load_cluster_config(&repo_root)?;
345 let nodes = load_all_nodes(&repo_root)?;
346
347 let healthy = nodes
348 .iter()
349 .filter(|n| n.health == HealthStatus::Healthy)
350 .count();
351 let total = nodes.len();
352
353 Ok(DatacenterResponse {
354 action: "status".into(),
355 message: format!(
356 "Cluster: {} node(s) ({} healthy), replication_factor={}, shards={}, strategy={}",
357 total,
358 healthy,
359 config.replication_factor,
360 config.shard_count,
361 match config.shard_strategy {
362 ShardStrategy::ConsistentHash => "consistent-hash",
363 ShardStrategy::RangePrefix => "range-prefix",
364 ShardStrategy::RoundRobin => "round-robin",
365 ShardStrategy::DomainAffinity => "domain-affinity",
366 }
367 ),
368 details: Some(serde_json::json!({
369 "config": config,
370 "nodes": nodes,
371 "summary": {
372 "total_nodes": total,
373 "healthy_nodes": healthy,
374 "total_storage": nodes.iter().map(|n| n.capacity.storage_total).sum::<u64>(),
375 "used_storage": nodes.iter().map(|n| n.capacity.storage_used).sum::<u64>(),
376 "total_objects": nodes.iter().map(|n| n.capacity.object_count).sum::<u64>(),
377 }
378 })),
379 })
380}
381
382pub fn execute_register_node(
384 node_id: String,
385 name: String,
386 endpoint: String,
387 region: String,
388 role: Option<String>,
389) -> Result<DatacenterResponse, LitError> {
390 let repo_root = find_repo_root()?;
391 let config = load_cluster_config(&repo_root)?;
392 let existing = load_all_nodes(&repo_root)?;
393
394 let role_enum = match role.as_deref() {
395 Some("primary") => NodeRole::Primary,
396 Some("replica") => NodeRole::Replica,
397 Some("relay") => NodeRole::Relay,
398 Some("observer") => NodeRole::Observer,
399 _ => NodeRole::Replica,
400 };
401
402 let node = ClusterNode {
403 node_id: node_id.clone(),
404 name: name.clone(),
405 endpoint,
406 region: region.clone(),
407 role: role_enum,
408 health: HealthStatus::Bootstrapping,
409 shard_ranges: compute_shard_ranges(&node_id, &existing, config.shard_count),
410 last_heartbeat: Utc::now().to_rfc3339(),
411 capacity: NodeCapacity {
412 storage_total: 0,
413 storage_used: 0,
414 object_count: 0,
415 max_connections: config.connection_pool_size,
416 active_connections: 0,
417 cpu_utilization: 0.0,
418 memory_utilization: 0.0,
419 },
420 registered_at: Utc::now().to_rfc3339(),
421 };
422
423 save_node(&repo_root, &node)?;
424
425 Ok(DatacenterResponse {
426 action: "register-node".into(),
427 message: format!(
428 "Node '{}' ({}) registered in region '{}' with {} shard(s)",
429 name,
430 node_id,
431 region,
432 node.shard_ranges.len()
433 ),
434 details: Some(serde_json::to_value(&node).unwrap_or_default()),
435 })
436}
437
438#[allow(clippy::too_many_arguments)]
440pub fn execute_configure(
441 replication_factor: Option<u32>,
442 shard_count: Option<u32>,
443 shard_strategy: Option<String>,
444 replication_mode: Option<String>,
445 connection_pool_size: Option<u32>,
446 metrics_enabled: Option<bool>,
447 metrics_port: Option<u16>,
448 write_concern: Option<u32>,
449) -> Result<DatacenterResponse, LitError> {
450 let repo_root = find_repo_root()?;
451 let mut config = load_cluster_config(&repo_root)?;
452
453 if let Some(rf) = replication_factor {
454 config.replication_factor = rf;
455 }
456 if let Some(sc) = shard_count {
457 config.shard_count = sc;
458 }
459 if let Some(ss) = shard_strategy {
460 config.shard_strategy = match ss.as_str() {
461 "consistent-hash" => ShardStrategy::ConsistentHash,
462 "range-prefix" => ShardStrategy::RangePrefix,
463 "round-robin" => ShardStrategy::RoundRobin,
464 "domain-affinity" => ShardStrategy::DomainAffinity,
465 _ => ShardStrategy::ConsistentHash,
466 };
467 }
468 if let Some(rm) = replication_mode {
469 config.replication_mode = match rm.as_str() {
470 "sync" | "synchronous" => ReplicationMode::Synchronous,
471 "async" | "asynchronous" => ReplicationMode::Asynchronous,
472 _ => ReplicationMode::SemiSync,
473 };
474 }
475 if let Some(cps) = connection_pool_size {
476 config.connection_pool_size = cps;
477 }
478 if let Some(me) = metrics_enabled {
479 config.metrics_enabled = me;
480 }
481 if let Some(mp) = metrics_port {
482 config.metrics_port = mp;
483 }
484 if let Some(wc) = write_concern {
485 config.write_concern = wc;
486 }
487
488 save_cluster_config(&repo_root, &config)?;
489
490 Ok(DatacenterResponse {
491 action: "configure".into(),
492 message: "Cluster configuration updated".into(),
493 details: Some(serde_json::to_value(&config).unwrap_or_default()),
494 })
495}
496
497pub fn execute_health() -> Result<DatacenterResponse, LitError> {
499 let repo_root = find_repo_root()?;
500 let config = load_cluster_config(&repo_root)?;
501 let nodes = load_all_nodes(&repo_root)?;
502
503 let mut health_report: Vec<serde_json::Value> = Vec::new();
504 let timeout_cutoff = Utc::now() - chrono::Duration::seconds(config.node_timeout_secs as i64);
505
506 for node in &nodes {
507 let last_hb = chrono::DateTime::parse_from_rfc3339(&node.last_heartbeat)
508 .map(|dt| dt.with_timezone(&Utc))
509 .unwrap_or_else(|_| Utc::now());
510
511 let effective_health = if last_hb < timeout_cutoff && node.health == HealthStatus::Healthy {
512 HealthStatus::Unreachable
513 } else {
514 node.health.clone()
515 };
516
517 let storage_pct = if node.capacity.storage_total > 0 {
518 (node.capacity.storage_used as f64 / node.capacity.storage_total as f64) * 100.0
519 } else {
520 0.0
521 };
522
523 health_report.push(serde_json::json!({
524 "node_id": node.node_id,
525 "name": node.name,
526 "role": node.role.to_string(),
527 "health": effective_health.to_string(),
528 "region": node.region,
529 "storage_pct": format!("{:.1}%", storage_pct),
530 "cpu": format!("{:.1}%", node.capacity.cpu_utilization * 100.0),
531 "memory": format!("{:.1}%", node.capacity.memory_utilization * 100.0),
532 "connections": format!("{}/{}", node.capacity.active_connections, node.capacity.max_connections),
533 "last_heartbeat": node.last_heartbeat,
534 }));
535 }
536
537 Ok(DatacenterResponse {
538 action: "health".into(),
539 message: format!("Health check for {} node(s)", nodes.len()),
540 details: Some(serde_json::to_value(&health_report).unwrap_or_default()),
541 })
542}
543
544pub fn execute_metrics() -> Result<DatacenterResponse, LitError> {
546 let repo_root = find_repo_root()?;
547 let metrics = collect_metrics(&repo_root);
548
549 let nodes = load_all_nodes(&repo_root)?;
551 let mut all_metrics = metrics;
552
553 all_metrics.push(Metric {
554 name: "lit_cluster_nodes_total".into(),
555 help: "Total nodes in cluster".into(),
556 metric_type: "gauge".into(),
557 value: nodes.len() as f64,
558 labels: HashMap::new(),
559 });
560
561 let healthy = nodes
562 .iter()
563 .filter(|n| n.health == HealthStatus::Healthy)
564 .count();
565 all_metrics.push(Metric {
566 name: "lit_cluster_nodes_healthy".into(),
567 help: "Healthy nodes in cluster".into(),
568 metric_type: "gauge".into(),
569 value: healthy as f64,
570 labels: HashMap::new(),
571 });
572
573 let exposition: String = all_metrics
575 .iter()
576 .map(|m| {
577 let labels_str = if m.labels.is_empty() {
578 String::new()
579 } else {
580 let pairs: Vec<String> = m
581 .labels
582 .iter()
583 .map(|(k, v)| format!("{}=\"{}\"", k, v))
584 .collect();
585 format!("{{{}}}", pairs.join(","))
586 };
587 format!(
588 "# HELP {} {}\n# TYPE {} {}\n{}{} {}",
589 m.name, m.help, m.name, m.metric_type, m.name, labels_str, m.value
590 )
591 })
592 .collect::<Vec<_>>()
593 .join("\n\n");
594
595 Ok(DatacenterResponse {
596 action: "metrics".into(),
597 message: format!("{} metric(s) collected", all_metrics.len()),
598 details: Some(serde_json::json!({
599 "metrics": all_metrics,
600 "exposition": exposition,
601 })),
602 })
603}
604
605pub fn execute_remove_node(node_id: String) -> Result<DatacenterResponse, LitError> {
607 let repo_root = find_repo_root()?;
608 let dir = nodes_dir(&repo_root);
609 let path = dir.join(format!("{}.json", node_id));
610
611 if !path.exists() {
612 return Err(LitError::general(format!("Node not found: {}", node_id)));
613 }
614
615 fs::remove_file(&path).map_err(|e| LitError::io(e.to_string()))?;
616
617 Ok(DatacenterResponse {
618 action: "remove-node".into(),
619 message: format!("Node '{}' removed from cluster", node_id),
620 details: None,
621 })
622}