Skip to main content

tatara_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4use crate::cluster::roles::RoleConfig;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ServerConfig {
8    #[serde(default = "default_http_addr")]
9    pub http_addr: String,
10    #[serde(default = "default_grpc_addr")]
11    pub grpc_addr: String,
12    #[serde(default = "default_log_level")]
13    pub log_level: String,
14    #[serde(default)]
15    pub state: StateConfig,
16    #[serde(default)]
17    pub scheduler: SchedulerConfig,
18    #[serde(default)]
19    pub cluster: ClusterConfig,
20    #[serde(default)]
21    pub p2p: P2pConfig,
22    #[serde(default)]
23    pub kindling: KindlingConfig,
24    #[serde(default)]
25    pub reconciler: ReconcilerConfig,
26    #[serde(default)]
27    pub nats: NatsConfig,
28    #[serde(default)]
29    pub sui: SuiConfig,
30    #[serde(default)]
31    pub ports: PortConfig,
32    #[serde(default)]
33    pub volumes: VolumeConfig,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct StateConfig {
38    #[serde(default = "default_server_state_dir")]
39    pub dir: PathBuf,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SchedulerConfig {
44    #[serde(default = "default_eval_interval")]
45    pub eval_interval_secs: u64,
46    #[serde(default = "default_heartbeat_grace")]
47    pub heartbeat_grace_secs: u64,
48}
49
50/// Reconciler configuration — controls the reconciliation loop.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ReconcilerConfig {
53    /// Reconciliation loop interval in seconds.
54    #[serde(default = "default_reconcile_interval")]
55    pub reconcile_interval_secs: u64,
56
57    /// Nix re-evaluation happens every Nth reconcile tick.
58    #[serde(default = "default_reeval_frequency")]
59    pub reeval_every_n_ticks: u64,
60
61    /// Max concurrent Nix evaluations.
62    #[serde(default = "default_max_concurrent_evals")]
63    pub max_concurrent_evals: usize,
64
65    /// Enable spec drift detection via Nix re-evaluation.
66    #[serde(default = "default_true")]
67    pub drift_detection: bool,
68
69    /// Enable source reconciliation (Pass 5).
70    #[serde(default = "default_true")]
71    pub source_reconciliation: bool,
72
73    /// Source re-check happens every Nth reconcile tick.
74    #[serde(default = "default_source_reeval_frequency")]
75    pub source_reeval_every_n_ticks: u64,
76
77    /// Timeout for `nix flake metadata` calls (seconds).
78    #[serde(default = "default_flake_metadata_timeout")]
79    pub flake_metadata_timeout_secs: u64,
80}
81
82/// Cluster configuration — gossip, raft, discovery.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ClusterConfig {
85    /// Unique cluster identifier (nodes with different IDs ignore each other).
86    #[serde(default = "default_cluster_id")]
87    pub cluster_id: String,
88
89    /// Gossip listen address (UDP).
90    #[serde(default = "default_gossip_addr")]
91    pub gossip_addr: String,
92
93    /// Raft listen address (HTTP-based RPCs).
94    #[serde(default = "default_raft_addr")]
95    pub raft_addr: String,
96
97    /// Static seed peers for gossip bootstrap (host:port).
98    /// In addition to mDNS and kindling fleet discovery.
99    #[serde(default)]
100    pub seed_peers: Vec<String>,
101
102    /// Enable mDNS discovery on local network.
103    #[serde(default = "default_true")]
104    pub mdns_discovery: bool,
105
106    /// Use kindling fleet peers as gossip seeds.
107    #[serde(default = "default_true")]
108    pub kindling_fleet_seeds: bool,
109
110    /// Node roles.
111    #[serde(default)]
112    pub roles: RoleConfig,
113
114    /// Bootstrap as single-node cluster if no peers found.
115    #[serde(default = "default_true")]
116    pub auto_bootstrap: bool,
117}
118
119/// P2P content-addressed cache configuration.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct P2pConfig {
122    /// Cache directory.
123    #[serde(default = "default_p2p_cache_dir")]
124    pub cache_dir: PathBuf,
125
126    /// Maximum cache size in MB. 0 = unlimited.
127    #[serde(default)]
128    pub max_cache_mb: u64,
129
130    /// Auto-replicate: eagerly fetch data from peers in background.
131    #[serde(default = "default_true")]
132    pub eager_replication: bool,
133}
134
135/// Kindling integration configuration.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct KindlingConfig {
138    /// Path to kindling's node.yaml. Empty = auto-detect.
139    #[serde(default)]
140    pub identity_path: Option<String>,
141
142    /// Path to kindling's report.json. Empty = auto-detect.
143    #[serde(default)]
144    pub report_path: Option<String>,
145
146    /// Kindling daemon HTTP address (for API client).
147    /// Empty = auto-detect from default kindling port.
148    #[serde(default = "default_kindling_addr")]
149    pub daemon_addr: String,
150
151    /// Publish kindling reports to the p2p cache.
152    #[serde(default = "default_true")]
153    pub publish_reports: bool,
154
155    /// Report refresh interval (seconds). 0 = use kindling's own interval.
156    #[serde(default)]
157    pub report_refresh_secs: u64,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ClientConfig {
162    #[serde(default = "default_client_server_addr")]
163    pub server_addr: String,
164    #[serde(default = "default_log_level")]
165    pub log_level: String,
166    #[serde(default = "default_alloc_dir")]
167    pub alloc_dir: PathBuf,
168    #[serde(default)]
169    pub resources: ResourceConfig,
170    #[serde(default)]
171    pub drivers: DriverConfig,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ResourceConfig {
176    #[serde(default)]
177    pub cpu_mhz: u64,
178    #[serde(default)]
179    pub memory_mb: u64,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct DriverConfig {
184    #[serde(default = "default_true")]
185    pub exec: bool,
186    #[serde(default = "default_true")]
187    pub oci: bool,
188    #[serde(default = "default_true")]
189    pub nix: bool,
190}
191
192impl ServerConfig {
193    pub fn load(path: Option<&str>) -> anyhow::Result<Self> {
194        let config_path = match path {
195            Some(p) => PathBuf::from(p),
196            None => default_config_dir().join("server.toml"),
197        };
198
199        if config_path.exists() {
200            let contents = std::fs::read_to_string(&config_path)?;
201            Ok(toml::from_str(&contents)?)
202        } else {
203            Ok(Self::default())
204        }
205    }
206}
207
208impl ClientConfig {
209    pub fn load(path: Option<&str>) -> anyhow::Result<Self> {
210        let config_path = match path {
211            Some(p) => PathBuf::from(p),
212            None => default_config_dir().join("client.toml"),
213        };
214
215        if config_path.exists() {
216            let contents = std::fs::read_to_string(&config_path)?;
217            Ok(toml::from_str(&contents)?)
218        } else {
219            Ok(Self::default())
220        }
221    }
222}
223
224impl Default for ServerConfig {
225    fn default() -> Self {
226        Self {
227            http_addr: default_http_addr(),
228            grpc_addr: default_grpc_addr(),
229            log_level: default_log_level(),
230            state: StateConfig::default(),
231            scheduler: SchedulerConfig::default(),
232            cluster: ClusterConfig::default(),
233            p2p: P2pConfig::default(),
234            kindling: KindlingConfig::default(),
235            reconciler: ReconcilerConfig::default(),
236            nats: NatsConfig::default(),
237            sui: SuiConfig::default(),
238            ports: PortConfig::default(),
239            volumes: VolumeConfig::default(),
240        }
241    }
242}
243
244impl Default for ReconcilerConfig {
245    fn default() -> Self {
246        Self {
247            reconcile_interval_secs: default_reconcile_interval(),
248            reeval_every_n_ticks: default_reeval_frequency(),
249            max_concurrent_evals: default_max_concurrent_evals(),
250            drift_detection: true,
251            source_reconciliation: true,
252            source_reeval_every_n_ticks: default_source_reeval_frequency(),
253            flake_metadata_timeout_secs: default_flake_metadata_timeout(),
254        }
255    }
256}
257
258impl Default for ClientConfig {
259    fn default() -> Self {
260        Self {
261            server_addr: default_client_server_addr(),
262            log_level: default_log_level(),
263            alloc_dir: default_alloc_dir(),
264            resources: ResourceConfig::default(),
265            drivers: DriverConfig::default(),
266        }
267    }
268}
269
270impl Default for StateConfig {
271    fn default() -> Self {
272        Self {
273            dir: default_server_state_dir(),
274        }
275    }
276}
277
278impl Default for SchedulerConfig {
279    fn default() -> Self {
280        Self {
281            eval_interval_secs: default_eval_interval(),
282            heartbeat_grace_secs: default_heartbeat_grace(),
283        }
284    }
285}
286
287impl Default for ClusterConfig {
288    fn default() -> Self {
289        Self {
290            cluster_id: default_cluster_id(),
291            gossip_addr: default_gossip_addr(),
292            raft_addr: default_raft_addr(),
293            seed_peers: Vec::new(),
294            mdns_discovery: true,
295            kindling_fleet_seeds: true,
296            roles: RoleConfig::default(),
297            auto_bootstrap: true,
298        }
299    }
300}
301
302impl Default for P2pConfig {
303    fn default() -> Self {
304        Self {
305            cache_dir: default_p2p_cache_dir(),
306            max_cache_mb: 0,
307            eager_replication: true,
308        }
309    }
310}
311
312impl Default for KindlingConfig {
313    fn default() -> Self {
314        Self {
315            identity_path: None,
316            report_path: None,
317            daemon_addr: default_kindling_addr(),
318            publish_reports: true,
319            report_refresh_secs: 0,
320        }
321    }
322}
323
324impl Default for ResourceConfig {
325    fn default() -> Self {
326        Self {
327            cpu_mhz: 0,
328            memory_mb: 0,
329        }
330    }
331}
332
333impl Default for DriverConfig {
334    fn default() -> Self {
335        Self {
336            exec: true,
337            oci: true,
338            nix: true,
339        }
340    }
341}
342
343fn default_config_dir() -> PathBuf {
344    dirs::config_dir()
345        .unwrap_or_else(|| PathBuf::from("~/.config"))
346        .join("tatara")
347}
348
349fn default_data_dir() -> PathBuf {
350    dirs::data_local_dir()
351        .unwrap_or_else(|| PathBuf::from("~/.local/share"))
352        .join("tatara")
353}
354
355fn default_http_addr() -> String {
356    "0.0.0.0:4646".to_string()
357}
358
359fn default_grpc_addr() -> String {
360    "0.0.0.0:4647".to_string()
361}
362
363fn default_gossip_addr() -> String {
364    "0.0.0.0:4648".to_string()
365}
366
367fn default_raft_addr() -> String {
368    "0.0.0.0:4649".to_string()
369}
370
371fn default_log_level() -> String {
372    "info".to_string()
373}
374
375fn default_cluster_id() -> String {
376    "tatara-default".to_string()
377}
378
379fn default_server_state_dir() -> PathBuf {
380    default_data_dir().join("server")
381}
382
383fn default_alloc_dir() -> PathBuf {
384    default_data_dir().join("alloc")
385}
386
387fn default_p2p_cache_dir() -> PathBuf {
388    default_data_dir().join("p2p")
389}
390
391fn default_client_server_addr() -> String {
392    "127.0.0.1:4647".to_string()
393}
394
395fn default_eval_interval() -> u64 {
396    1
397}
398
399fn default_reconcile_interval() -> u64 {
400    10
401}
402
403fn default_reeval_frequency() -> u64 {
404    6
405}
406
407fn default_max_concurrent_evals() -> usize {
408    2
409}
410
411fn default_heartbeat_grace() -> u64 {
412    30
413}
414
415fn default_source_reeval_frequency() -> u64 {
416    3
417}
418
419fn default_flake_metadata_timeout() -> u64 {
420    30
421}
422
423fn default_kindling_addr() -> String {
424    "http://127.0.0.1:3000".to_string()
425}
426
427fn default_true() -> bool {
428    true
429}
430
431// ── New subsystem configs ───────────────────────────────────────
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct NatsConfig {
435    #[serde(default)]
436    pub enabled: bool,
437    #[serde(default = "default_nats_url")]
438    pub url: String,
439}
440
441impl Default for NatsConfig {
442    fn default() -> Self {
443        Self {
444            enabled: false,
445            url: default_nats_url(),
446        }
447    }
448}
449
450fn default_nats_url() -> String {
451    "nats://127.0.0.1:4222".to_string()
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct SuiConfig {
456    #[serde(default)]
457    pub daemon_addr: Option<String>,
458}
459
460impl Default for SuiConfig {
461    fn default() -> Self {
462        Self { daemon_addr: None }
463    }
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct PortConfig {
468    #[serde(default = "default_port_start")]
469    pub range_start: u16,
470    #[serde(default = "default_port_end")]
471    pub range_end: u16,
472}
473
474impl Default for PortConfig {
475    fn default() -> Self {
476        Self {
477            range_start: default_port_start(),
478            range_end: default_port_end(),
479        }
480    }
481}
482
483fn default_port_start() -> u16 {
484    20000
485}
486fn default_port_end() -> u16 {
487    32000
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct VolumeConfig {
492    #[serde(default = "default_volume_dir")]
493    pub dir: PathBuf,
494}
495
496impl Default for VolumeConfig {
497    fn default() -> Self {
498        Self {
499            dir: default_volume_dir(),
500        }
501    }
502}
503
504fn default_volume_dir() -> PathBuf {
505    dirs::data_dir()
506        .unwrap_or_else(|| PathBuf::from("/tmp"))
507        .join("tatara")
508        .join("volumes")
509}
510
511// ── shikumi::TieredConfig — prime directive ────────────────
512//
513// Operators reach via:
514//   tatara config-show bare           # zero-opinion floor
515//   tatara config-show default        # prescribed defaults
516//   TATARA_TIER=bare tatara server    # explicit tier override
517//
518// bare() = empty addrs, zero intervals, false flags. The documented
519// minimum that doesn't bind any port, gossip, raft, or NATS endpoint.
520// prescribed_default() = current Default impl (curated tatara server
521// + client defaults).
522
523impl shikumi::TieredConfig for ServerConfig {
524    fn bare() -> Self {
525        Self {
526            http_addr: String::new(),
527            grpc_addr: String::new(),
528            log_level: String::new(),
529            state: <StateConfig as shikumi::TieredConfig>::bare(),
530            scheduler: <SchedulerConfig as shikumi::TieredConfig>::bare(),
531            cluster: <ClusterConfig as shikumi::TieredConfig>::bare(),
532            p2p: <P2pConfig as shikumi::TieredConfig>::bare(),
533            kindling: <KindlingConfig as shikumi::TieredConfig>::bare(),
534            reconciler: <ReconcilerConfig as shikumi::TieredConfig>::bare(),
535            nats: <NatsConfig as shikumi::TieredConfig>::bare(),
536            sui: <SuiConfig as shikumi::TieredConfig>::bare(),
537            ports: <PortConfig as shikumi::TieredConfig>::bare(),
538            volumes: <VolumeConfig as shikumi::TieredConfig>::bare(),
539        }
540    }
541    fn prescribed_default() -> Self { Self::default() }
542}
543
544impl shikumi::TieredConfig for ClientConfig {
545    fn bare() -> Self {
546        Self {
547            server_addr: String::new(),
548            log_level: String::new(),
549            alloc_dir: PathBuf::new(),
550            resources: ResourceConfig::default(),
551            drivers: <DriverConfig as shikumi::TieredConfig>::bare(),
552        }
553    }
554    fn prescribed_default() -> Self { Self::default() }
555}
556
557impl shikumi::TieredConfig for StateConfig {
558    fn bare() -> Self { Self { dir: PathBuf::new() } }
559    fn prescribed_default() -> Self { Self::default() }
560}
561
562impl shikumi::TieredConfig for SchedulerConfig {
563    fn bare() -> Self { Self { eval_interval_secs: 0, heartbeat_grace_secs: 0 } }
564    fn prescribed_default() -> Self { Self::default() }
565}
566
567impl shikumi::TieredConfig for ReconcilerConfig {
568    fn bare() -> Self {
569        Self {
570            reconcile_interval_secs: 0,
571            reeval_every_n_ticks: 0,
572            max_concurrent_evals: 0,
573            drift_detection: false,
574            source_reconciliation: false,
575            source_reeval_every_n_ticks: 0,
576            flake_metadata_timeout_secs: 0,
577        }
578    }
579    fn prescribed_default() -> Self { Self::default() }
580}
581
582impl shikumi::TieredConfig for ClusterConfig {
583    fn bare() -> Self {
584        Self {
585            cluster_id: String::new(),
586            gossip_addr: String::new(),
587            raft_addr: String::new(),
588            seed_peers: Vec::new(),
589            mdns_discovery: false,
590            kindling_fleet_seeds: false,
591            roles: RoleConfig::default(),
592            auto_bootstrap: false,
593        }
594    }
595    fn prescribed_default() -> Self { Self::default() }
596}
597
598impl shikumi::TieredConfig for P2pConfig {
599    fn bare() -> Self {
600        Self {
601            cache_dir: PathBuf::new(),
602            max_cache_mb: 0,
603            eager_replication: false,
604        }
605    }
606    fn prescribed_default() -> Self { Self::default() }
607}
608
609impl shikumi::TieredConfig for KindlingConfig {
610    fn bare() -> Self { <Self as shikumi::TieredConfig>::prescribed_default() }
611    fn prescribed_default() -> Self { Self::default() }
612}
613
614impl shikumi::TieredConfig for DriverConfig {
615    fn bare() -> Self { <Self as shikumi::TieredConfig>::prescribed_default() }
616    fn prescribed_default() -> Self { Self::default() }
617}
618
619impl shikumi::TieredConfig for NatsConfig {
620    fn bare() -> Self { <Self as shikumi::TieredConfig>::prescribed_default() }
621    fn prescribed_default() -> Self { Self::default() }
622}
623
624impl shikumi::TieredConfig for SuiConfig {
625    fn bare() -> Self { <Self as shikumi::TieredConfig>::prescribed_default() }
626    fn prescribed_default() -> Self { Self::default() }
627}
628
629impl shikumi::TieredConfig for PortConfig {
630    fn bare() -> Self { <Self as shikumi::TieredConfig>::prescribed_default() }
631    fn prescribed_default() -> Self { Self::default() }
632}
633
634impl shikumi::TieredConfig for VolumeConfig {
635    fn bare() -> Self { Self { dir: PathBuf::new() } }
636    fn prescribed_default() -> Self { Self::default() }
637}
638
639#[cfg(test)]
640mod tiered_tests {
641    use super::*;
642    use shikumi::{ConfigTier, TieredConfig};
643
644    #[test]
645    fn server_config_bare_is_zero_opinion() {
646        let b = <ServerConfig as TieredConfig>::bare();
647        assert_eq!(b.http_addr, "");
648        assert_eq!(b.grpc_addr, "");
649        assert_eq!(b.log_level, "");
650        assert!(b.state.dir.as_os_str().is_empty());
651        assert_eq!(b.scheduler.eval_interval_secs, 0);
652        assert!(!b.cluster.mdns_discovery);
653        assert!(!b.reconciler.drift_detection);
654    }
655
656    #[test]
657    fn server_config_prescribed_matches_default() {
658        let p = <ServerConfig as TieredConfig>::prescribed_default();
659        let d = ServerConfig::default();
660        assert_eq!(p.http_addr, d.http_addr);
661        assert_eq!(p.log_level, d.log_level);
662    }
663
664    #[test]
665    fn server_config_diff_bare_vs_default_is_non_empty() {
666        let b = <ServerConfig as TieredConfig>::bare();
667        let d = <ServerConfig as TieredConfig>::prescribed_default();
668        let diff = d.diff_against(&b);
669        assert!(!diff.is_empty_diff(), "bare and prescribed_default must differ");
670    }
671
672    #[test]
673    fn server_config_resolve_tier_dispatches_correctly() {
674        assert_eq!(
675            <ServerConfig as TieredConfig>::resolve_tier(ConfigTier::Bare).http_addr,
676            ""
677        );
678        assert!(!<ServerConfig as TieredConfig>::resolve_tier(ConfigTier::Default)
679            .http_addr
680            .is_empty());
681    }
682
683    #[test]
684    fn client_config_bare_is_zero_opinion() {
685        let b = <ClientConfig as TieredConfig>::bare();
686        assert_eq!(b.server_addr, "");
687        assert_eq!(b.log_level, "");
688        assert!(b.alloc_dir.as_os_str().is_empty());
689    }
690}