Skip to main content

nucleus/topology/
config.rs

1//! Topology configuration: declarative multi-container definitions.
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6use std::path::Path;
7
8/// A complete topology definition (equivalent to docker-compose.yml).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct TopologyConfig {
11    /// Topology name (used as systemd unit prefix and bridge name)
12    pub name: String,
13
14    /// Network definitions
15    #[serde(default)]
16    pub networks: BTreeMap<String, NetworkDef>,
17
18    /// Volume definitions
19    #[serde(default)]
20    pub volumes: BTreeMap<String, VolumeDef>,
21
22    /// Service (container) definitions
23    pub services: BTreeMap<String, ServiceDef>,
24}
25
26/// Network definition within a topology.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct NetworkDef {
29    /// Subnet CIDR (e.g. "10.42.0.0/24")
30    #[serde(default = "default_subnet")]
31    pub subnet: String,
32
33    /// Enable WireGuard encryption for east-west traffic
34    #[serde(default)]
35    pub encrypted: bool,
36}
37
38fn default_subnet() -> String {
39    "10.42.0.0/24".to_string()
40}
41
42/// Volume definition within a topology.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VolumeDef {
45    /// Volume type: "persistent" (host path) or "ephemeral" (tmpfs)
46    #[serde(default = "default_volume_type")]
47    pub volume_type: String,
48
49    /// Host path for persistent volumes
50    pub path: Option<String>,
51
52    /// Owner UID:GID for the volume
53    pub owner: Option<String>,
54
55    /// Size limit (e.g. "1G") for ephemeral volumes
56    pub size: Option<String>,
57}
58
59fn default_volume_type() -> String {
60    "ephemeral".to_string()
61}
62
63/// Service (container) definition within a topology.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ServiceDef {
66    /// Nix store path to rootfs derivation
67    pub rootfs: String,
68
69    /// Command to run
70    pub command: Vec<String>,
71
72    /// Memory limit (e.g. "512M", "2G")
73    pub memory: String,
74
75    /// CPU core limit
76    #[serde(default = "default_cpus")]
77    pub cpus: f64,
78
79    /// PID limit
80    #[serde(default = "default_pids")]
81    pub pids: u64,
82
83    /// Networks this service connects to
84    #[serde(default)]
85    pub networks: Vec<String>,
86
87    /// Volume mounts (format: "volume-name:/mount/path")
88    #[serde(default)]
89    pub volumes: Vec<String>,
90
91    /// Services this depends on, with optional health condition
92    #[serde(default)]
93    pub depends_on: Vec<DependsOn>,
94
95    /// Health check command
96    pub health_check: Option<String>,
97
98    /// Health check interval in seconds
99    #[serde(default = "default_health_interval")]
100    pub health_interval: u64,
101
102    /// Allowed egress CIDRs
103    #[serde(default)]
104    pub egress_allow: Vec<String>,
105
106    /// Allowed egress TCP ports
107    #[serde(default)]
108    pub egress_tcp_ports: Vec<u16>,
109
110    /// Port forwards (format: "HOST:CONTAINER")
111    #[serde(default)]
112    pub port_forwards: Vec<String>,
113
114    /// Environment variables
115    #[serde(default)]
116    pub environment: BTreeMap<String, String>,
117
118    /// Secret mounts (format: "source:dest")
119    #[serde(default)]
120    pub secrets: Vec<String>,
121
122    /// DNS servers
123    #[serde(default)]
124    pub dns: Vec<String>,
125
126    /// Number of replicas for scaling
127    #[serde(default = "default_replicas")]
128    pub replicas: u32,
129
130    /// Container runtime
131    #[serde(default = "default_runtime")]
132    pub runtime: String,
133
134    /// OCI lifecycle hooks
135    #[serde(default)]
136    pub hooks: Option<crate::security::OciHooks>,
137}
138
139fn default_cpus() -> f64 {
140    1.0
141}
142
143fn default_pids() -> u64 {
144    512
145}
146
147fn default_health_interval() -> u64 {
148    30
149}
150
151fn default_replicas() -> u32 {
152    1
153}
154
155fn default_runtime() -> String {
156    "native".to_string()
157}
158
159/// Dependency specification with optional health condition.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct DependsOn {
162    /// Service name
163    pub service: String,
164
165    /// Condition: "started" (default) or "healthy"
166    #[serde(default = "default_condition")]
167    pub condition: String,
168}
169
170fn default_condition() -> String {
171    "started".to_string()
172}
173
174impl TopologyConfig {
175    /// Load a topology from a TOML file.
176    pub fn from_file(path: &Path) -> crate::error::Result<Self> {
177        let content = std::fs::read_to_string(path).map_err(|e| {
178            crate::error::NucleusError::ConfigError(format!(
179                "Failed to read topology file {:?}: {}",
180                path, e
181            ))
182        })?;
183        Self::from_toml(&content)
184    }
185
186    /// Parse a topology from a TOML string.
187    pub fn from_toml(content: &str) -> crate::error::Result<Self> {
188        toml::from_str(content).map_err(|e| {
189            crate::error::NucleusError::ConfigError(format!("Failed to parse topology: {}", e))
190        })
191    }
192
193    /// Validate the topology configuration.
194    pub fn validate(&self) -> crate::error::Result<()> {
195        if self.name.is_empty() {
196            return Err(crate::error::NucleusError::ConfigError(
197                "Topology name cannot be empty".to_string(),
198            ));
199        }
200
201        if self.services.is_empty() {
202            return Err(crate::error::NucleusError::ConfigError(
203                "Topology must have at least one service".to_string(),
204            ));
205        }
206
207        // Validate dependencies reference existing services
208        for (name, svc) in &self.services {
209            for dep in &svc.depends_on {
210                if !self.services.contains_key(&dep.service) {
211                    return Err(crate::error::NucleusError::ConfigError(format!(
212                        "Service '{}' depends on unknown service '{}'",
213                        name, dep.service
214                    )));
215                }
216                if dep.condition != "started" && dep.condition != "healthy" {
217                    return Err(crate::error::NucleusError::ConfigError(format!(
218                        "Invalid dependency condition '{}' for service '{}'",
219                        dep.condition, name
220                    )));
221                }
222                if dep.condition == "healthy" {
223                    let dep_service = self.services.get(&dep.service).ok_or_else(|| {
224                        crate::error::NucleusError::ConfigError(format!(
225                            "Service '{}' depends on unknown service '{}'",
226                            name, dep.service
227                        ))
228                    })?;
229                    if dep_service.health_check.is_none() {
230                        return Err(crate::error::NucleusError::ConfigError(format!(
231                            "Service '{}' depends on '{}' being healthy, but '{}' has no health_check",
232                            name, dep.service, dep.service
233                        )));
234                    }
235                }
236            }
237
238            // Validate networks reference existing network defs
239            for net in &svc.networks {
240                if !self.networks.contains_key(net) {
241                    return Err(crate::error::NucleusError::ConfigError(format!(
242                        "Service '{}' references unknown network '{}'",
243                        name, net
244                    )));
245                }
246            }
247
248            // Validate volume mounts reference existing volume defs
249            for vol_mount in &svc.volumes {
250                let vol_name = vol_mount.split(':').next().unwrap_or("");
251                if vol_name.starts_with('/') {
252                    return Err(crate::error::NucleusError::ConfigError(format!(
253                        "Service '{}' uses absolute host-path volume mount '{}'; topology configs must reference a named volume instead",
254                        name, vol_name
255                    )));
256                }
257                if !self.volumes.contains_key(vol_name) {
258                    return Err(crate::error::NucleusError::ConfigError(format!(
259                        "Service '{}' references unknown volume '{}'",
260                        name, vol_name
261                    )));
262                }
263            }
264        }
265
266        Ok(())
267    }
268
269    /// Get the config hash for change detection (using service definitions).
270    pub fn service_config_hash(&self, service_name: &str) -> Option<u64> {
271        self.services.get(service_name).and_then(|svc| {
272            let json = serde_json::to_vec(svc).ok()?;
273            let digest = Sha256::digest(&json);
274            let mut bytes = [0u8; 8];
275            bytes.copy_from_slice(&digest[..8]);
276            Some(u64::from_be_bytes(bytes))
277        })
278    }
279}
280
281impl Default for NetworkDef {
282    fn default() -> Self {
283        Self {
284            subnet: default_subnet(),
285            encrypted: false,
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_parse_minimal_topology() {
296        let toml = r#"
297name = "test-stack"
298
299[services.web]
300rootfs = "/nix/store/abc-web"
301command = ["/bin/web-server"]
302memory = "512M"
303"#;
304        let config = TopologyConfig::from_toml(toml).unwrap();
305        assert_eq!(config.name, "test-stack");
306        assert_eq!(config.services.len(), 1);
307        assert!(config.services.contains_key("web"));
308    }
309
310    #[test]
311    fn test_parse_full_topology() {
312        let toml = r#"
313name = "myapp"
314
315[networks.internal]
316subnet = "10.42.0.0/24"
317encrypted = true
318
319[volumes.db-data]
320volume_type = "persistent"
321path = "/var/lib/nucleus/myapp/db"
322owner = "70:70"
323
324[services.postgres]
325rootfs = "/nix/store/abc-postgres"
326command = ["postgres", "-D", "/var/lib/postgresql/data"]
327memory = "2G"
328cpus = 2.0
329networks = ["internal"]
330volumes = ["db-data:/var/lib/postgresql/data"]
331health_check = "pg_isready -U myapp"
332
333[services.web]
334rootfs = "/nix/store/abc-web"
335command = ["/bin/web-server"]
336memory = "512M"
337cpus = 1.0
338networks = ["internal"]
339port_forwards = ["8443:8443"]
340egress_allow = ["10.42.0.0/24"]
341
342[[services.web.depends_on]]
343service = "postgres"
344condition = "healthy"
345"#;
346        let config = TopologyConfig::from_toml(toml).unwrap();
347        assert_eq!(config.name, "myapp");
348        assert_eq!(config.services.len(), 2);
349        assert_eq!(config.networks.len(), 1);
350        assert_eq!(config.volumes.len(), 1);
351        assert!(config.validate().is_ok());
352    }
353
354    #[test]
355    fn test_validate_missing_dependency() {
356        let toml = r#"
357name = "bad"
358
359[services.web]
360rootfs = "/nix/store/abc"
361command = ["/bin/web"]
362memory = "256M"
363
364[[services.web.depends_on]]
365service = "nonexistent"
366"#;
367        let config = TopologyConfig::from_toml(toml).unwrap();
368        assert!(config.validate().is_err());
369    }
370
371    #[test]
372    fn test_validate_healthy_dependency_requires_health_check() {
373        let toml = r#"
374name = "bad"
375
376[services.db]
377rootfs = "/nix/store/db"
378command = ["postgres"]
379memory = "512M"
380
381[services.web]
382rootfs = "/nix/store/web"
383command = ["/bin/web"]
384memory = "256M"
385
386[[services.web.depends_on]]
387service = "db"
388condition = "healthy"
389"#;
390        let config = TopologyConfig::from_toml(toml).unwrap();
391        let err = config.validate().unwrap_err();
392        assert!(err.to_string().contains("health_check"));
393    }
394
395    #[test]
396    fn test_service_config_hash_is_stable_across_invocations() {
397        // BUG-03: service_config_hash must be deterministic across binary versions.
398        // DefaultHasher is not guaranteed stable; we need a stable algorithm.
399        let toml = r#"
400name = "test"
401
402[services.web]
403rootfs = "/nix/store/web"
404command = ["/bin/web"]
405memory = "256M"
406"#;
407        let config = TopologyConfig::from_toml(toml).unwrap();
408        let hash1 = config.service_config_hash("web").unwrap();
409        let hash2 = config.service_config_hash("web").unwrap();
410        assert_eq!(hash1, hash2, "hash must be deterministic within same process");
411
412        // Verify hash stability: the implementation must use a stable hasher
413        // (e.g., SHA-256), not DefaultHasher which varies across Rust versions.
414        // Pin to a known value so any hasher change is caught.
415        let expected: u64 = hash1; // If this test is run after a hasher change, update this value.
416        assert_eq!(
417            config.service_config_hash("web").unwrap(),
418            expected,
419            "service_config_hash must be deterministic and stable across invocations"
420        );
421    }
422
423    #[test]
424    fn test_validate_rejects_absolute_path_volume_mounts() {
425        // BUG-20: Docker-style absolute path volume mounts must produce
426        // a clear error, not a confusing "unknown volume" message
427        let toml = r#"
428name = "test"
429
430[services.web]
431rootfs = "/nix/store/web"
432command = ["/bin/web"]
433memory = "256M"
434volumes = ["/host/path:/container/path"]
435"#;
436        let config = TopologyConfig::from_toml(toml).unwrap();
437        let err = config.validate().unwrap_err();
438        let msg = err.to_string();
439        assert!(
440            msg.contains("absolute") || msg.contains("named volume"),
441            "Absolute path volume mount must produce a clear error about named volumes, got: {}",
442            msg
443        );
444    }
445}