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, PathBuf};
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" or "HOST_IP: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    /// Workload user name or numeric uid.
119    #[serde(default)]
120    pub user: Option<String>,
121
122    /// Workload group name or numeric gid.
123    #[serde(default)]
124    pub group: Option<String>,
125
126    /// Supplementary workload groups (names or numeric gids).
127    #[serde(default)]
128    pub additional_groups: Vec<String>,
129
130    /// Secret mounts (format: "source:dest")
131    #[serde(default)]
132    pub secrets: Vec<String>,
133
134    /// DNS servers
135    #[serde(default)]
136    pub dns: Vec<String>,
137
138    /// Number of replicas for scaling
139    #[serde(default = "default_replicas")]
140    pub replicas: u32,
141
142    /// Container runtime
143    #[serde(default = "default_runtime")]
144    pub runtime: String,
145
146    /// OCI lifecycle hooks
147    #[serde(default)]
148    pub hooks: Option<crate::security::OciHooks>,
149}
150
151fn default_cpus() -> f64 {
152    1.0
153}
154
155fn default_pids() -> u64 {
156    512
157}
158
159fn default_health_interval() -> u64 {
160    30
161}
162
163fn default_replicas() -> u32 {
164    1
165}
166
167fn default_runtime() -> String {
168    "native".to_string()
169}
170
171/// Dependency specification with optional health condition.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct DependsOn {
174    /// Service name
175    pub service: String,
176
177    /// Condition: "started" (default) or "healthy"
178    #[serde(default = "default_condition")]
179    pub condition: String,
180}
181
182fn default_condition() -> String {
183    "started".to_string()
184}
185
186/// Parsed service volume reference.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ServiceVolumeMount {
189    /// Referenced topology volume name.
190    pub volume: String,
191    /// Destination path inside the container.
192    pub dest: PathBuf,
193    /// Whether the mount is read-only.
194    pub read_only: bool,
195}
196
197pub(crate) fn parse_service_volume_mount(spec: &str) -> crate::error::Result<ServiceVolumeMount> {
198    let parts: Vec<&str> = spec.split(':').collect();
199    let (volume, dest, read_only) = match parts.as_slice() {
200        [volume, dest] => (*volume, *dest, false),
201        [volume, dest, mode] if *mode == "ro" => (*volume, *dest, true),
202        [volume, dest, mode] if *mode == "rw" => (*volume, *dest, false),
203        _ => {
204            return Err(crate::error::NucleusError::ConfigError(format!(
205                "Invalid volume mount '{}', expected VOLUME:DEST[:ro|rw]",
206                spec
207            )));
208        }
209    };
210
211    if volume.is_empty() {
212        return Err(crate::error::NucleusError::ConfigError(format!(
213            "Volume mount '{}' must name a topology volume",
214            spec
215        )));
216    }
217
218    let dest = crate::filesystem::normalize_container_destination(Path::new(dest))?;
219    Ok(ServiceVolumeMount {
220        volume: volume.to_string(),
221        dest,
222        read_only,
223    })
224}
225
226pub(crate) fn parse_volume_owner(owner: &str) -> crate::error::Result<(u32, u32)> {
227    let (uid, gid) = owner.split_once(':').ok_or_else(|| {
228        crate::error::NucleusError::ConfigError(format!(
229            "Invalid volume owner '{}', expected UID:GID",
230            owner
231        ))
232    })?;
233    let uid = uid.parse::<u32>().map_err(|e| {
234        crate::error::NucleusError::ConfigError(format!(
235            "Invalid volume owner UID '{}' in '{}': {}",
236            uid, owner, e
237        ))
238    })?;
239    let gid = gid.parse::<u32>().map_err(|e| {
240        crate::error::NucleusError::ConfigError(format!(
241            "Invalid volume owner GID '{}' in '{}': {}",
242            gid, owner, e
243        ))
244    })?;
245    Ok((uid, gid))
246}
247
248impl TopologyConfig {
249    /// Load a topology from a TOML file.
250    pub fn from_file(path: &Path) -> crate::error::Result<Self> {
251        let content = std::fs::read_to_string(path).map_err(|e| {
252            crate::error::NucleusError::ConfigError(format!(
253                "Failed to read topology file {:?}: {}",
254                path, e
255            ))
256        })?;
257        Self::from_toml(&content)
258    }
259
260    /// Parse a topology from a TOML string.
261    pub fn from_toml(content: &str) -> crate::error::Result<Self> {
262        toml::from_str(content).map_err(|e| {
263            crate::error::NucleusError::ConfigError(format!("Failed to parse topology: {}", e))
264        })
265    }
266
267    /// Validate the topology configuration.
268    pub fn validate(&self) -> crate::error::Result<()> {
269        if self.name.is_empty() {
270            return Err(crate::error::NucleusError::ConfigError(
271                "Topology name cannot be empty".to_string(),
272            ));
273        }
274
275        // Validate topology name and all service keys use safe characters,
276        // preventing path traversal when they are used in temp-file paths
277        // (e.g. /tmp/nucleus-hooks-{topology}-{service}.json).
278        crate::container::validate_container_name(&self.name).map_err(|_| {
279            crate::error::NucleusError::ConfigError(format!(
280                "Topology name '{}' contains invalid characters (allowed: a-zA-Z0-9, '-', '_', '.')",
281                self.name
282            ))
283        })?;
284        for service_name in self.services.keys() {
285            crate::container::validate_container_name(service_name).map_err(|_| {
286                crate::error::NucleusError::ConfigError(format!(
287                    "Service name '{}' contains invalid characters (allowed: a-zA-Z0-9, '-', '_', '.')",
288                    service_name
289                ))
290            })?;
291        }
292
293        if self.services.is_empty() {
294            return Err(crate::error::NucleusError::ConfigError(
295                "Topology must have at least one service".to_string(),
296            ));
297        }
298
299        for (name, volume) in &self.volumes {
300            match volume.volume_type.as_str() {
301                "persistent" => {
302                    let path = volume.path.as_ref().ok_or_else(|| {
303                        crate::error::NucleusError::ConfigError(format!(
304                            "Persistent volume '{}' must define path",
305                            name
306                        ))
307                    })?;
308                    if !Path::new(path).is_absolute() {
309                        return Err(crate::error::NucleusError::ConfigError(format!(
310                            "Persistent volume '{}' path must be absolute: {}",
311                            name, path
312                        )));
313                    }
314                }
315                "ephemeral" => {
316                    if volume.path.is_some() {
317                        return Err(crate::error::NucleusError::ConfigError(format!(
318                            "Ephemeral volume '{}' must not define path",
319                            name
320                        )));
321                    }
322                }
323                other => {
324                    return Err(crate::error::NucleusError::ConfigError(format!(
325                        "Volume '{}' has unsupported type '{}'",
326                        name, other
327                    )));
328                }
329            }
330
331            if let Some(owner) = &volume.owner {
332                parse_volume_owner(owner)?;
333            }
334        }
335
336        // Validate dependencies reference existing services
337        for (name, svc) in &self.services {
338            for dep in &svc.depends_on {
339                if !self.services.contains_key(&dep.service) {
340                    return Err(crate::error::NucleusError::ConfigError(format!(
341                        "Service '{}' depends on unknown service '{}'",
342                        name, dep.service
343                    )));
344                }
345                if dep.condition != "started" && dep.condition != "healthy" {
346                    return Err(crate::error::NucleusError::ConfigError(format!(
347                        "Invalid dependency condition '{}' for service '{}'",
348                        dep.condition, name
349                    )));
350                }
351                if dep.condition == "healthy" {
352                    let dep_service = self.services.get(&dep.service).ok_or_else(|| {
353                        crate::error::NucleusError::ConfigError(format!(
354                            "Service '{}' depends on unknown service '{}'",
355                            name, dep.service
356                        ))
357                    })?;
358                    if dep_service.health_check.is_none() {
359                        return Err(crate::error::NucleusError::ConfigError(format!(
360                            "Service '{}' depends on '{}' being healthy, but '{}' has no health_check",
361                            name, dep.service, dep.service
362                        )));
363                    }
364                }
365            }
366
367            // Validate networks reference existing network defs
368            for net in &svc.networks {
369                if !self.networks.contains_key(net) {
370                    return Err(crate::error::NucleusError::ConfigError(format!(
371                        "Service '{}' references unknown network '{}'",
372                        name, net
373                    )));
374                }
375            }
376
377            // Validate volume mounts reference existing volume defs
378            for vol_mount in &svc.volumes {
379                let parsed = parse_service_volume_mount(vol_mount)?;
380                if parsed.volume.starts_with('/') {
381                    return Err(crate::error::NucleusError::ConfigError(format!(
382                        "Service '{}' uses absolute host-path volume mount '{}'; topology configs must reference a named volume instead",
383                        name, parsed.volume
384                    )));
385                }
386                if !self.volumes.contains_key(&parsed.volume) {
387                    return Err(crate::error::NucleusError::ConfigError(format!(
388                        "Service '{}' references unknown volume '{}'",
389                        name, parsed.volume
390                    )));
391                }
392            }
393        }
394
395        Ok(())
396    }
397
398    /// Get the config hash for change detection (using service definitions).
399    pub fn service_config_hash(&self, service_name: &str) -> Option<u64> {
400        self.services.get(service_name).and_then(|svc| {
401            let json = serde_json::to_vec(svc).ok()?;
402            let digest = Sha256::digest(&json);
403            let mut bytes = [0u8; 8];
404            bytes.copy_from_slice(&digest[..8]);
405            Some(u64::from_be_bytes(bytes))
406        })
407    }
408}
409
410impl Default for NetworkDef {
411    fn default() -> Self {
412        Self {
413            subnet: default_subnet(),
414            encrypted: false,
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn test_parse_minimal_topology() {
425        let toml = r#"
426name = "test-stack"
427
428[services.web]
429rootfs = "/nix/store/abc-web"
430command = ["/bin/web-server"]
431memory = "512M"
432"#;
433        let config = TopologyConfig::from_toml(toml).unwrap();
434        assert_eq!(config.name, "test-stack");
435        assert_eq!(config.services.len(), 1);
436        assert!(config.services.contains_key("web"));
437    }
438
439    #[test]
440    fn test_parse_full_topology() {
441        let toml = r#"
442name = "myapp"
443
444[networks.internal]
445subnet = "10.42.0.0/24"
446encrypted = true
447
448[volumes.db-data]
449volume_type = "persistent"
450path = "/var/lib/nucleus/myapp/db"
451owner = "70:70"
452
453[services.postgres]
454rootfs = "/nix/store/abc-postgres"
455command = ["postgres", "-D", "/var/lib/postgresql/data"]
456memory = "2G"
457cpus = 2.0
458networks = ["internal"]
459volumes = ["db-data:/var/lib/postgresql/data"]
460health_check = "pg_isready -U myapp"
461
462[services.web]
463rootfs = "/nix/store/abc-web"
464command = ["/bin/web-server"]
465memory = "512M"
466cpus = 1.0
467networks = ["internal"]
468port_forwards = ["8443:8443"]
469egress_allow = ["10.42.0.0/24"]
470
471[[services.web.depends_on]]
472service = "postgres"
473condition = "healthy"
474"#;
475        let config = TopologyConfig::from_toml(toml).unwrap();
476        assert_eq!(config.name, "myapp");
477        assert_eq!(config.services.len(), 2);
478        assert_eq!(config.networks.len(), 1);
479        assert_eq!(config.volumes.len(), 1);
480        assert!(config.validate().is_ok());
481    }
482
483    #[test]
484    fn test_validate_missing_dependency() {
485        let toml = r#"
486name = "bad"
487
488[services.web]
489rootfs = "/nix/store/abc"
490command = ["/bin/web"]
491memory = "256M"
492
493[[services.web.depends_on]]
494service = "nonexistent"
495"#;
496        let config = TopologyConfig::from_toml(toml).unwrap();
497        assert!(config.validate().is_err());
498    }
499
500    #[test]
501    fn test_validate_healthy_dependency_requires_health_check() {
502        let toml = r#"
503name = "bad"
504
505[services.db]
506rootfs = "/nix/store/db"
507command = ["postgres"]
508memory = "512M"
509
510[services.web]
511rootfs = "/nix/store/web"
512command = ["/bin/web"]
513memory = "256M"
514
515[[services.web.depends_on]]
516service = "db"
517condition = "healthy"
518"#;
519        let config = TopologyConfig::from_toml(toml).unwrap();
520        let err = config.validate().unwrap_err();
521        assert!(err.to_string().contains("health_check"));
522    }
523
524    #[test]
525    fn test_service_config_hash_is_stable_across_invocations() {
526        // BUG-03: service_config_hash must be deterministic across binary versions.
527        // DefaultHasher is not guaranteed stable; we need a stable algorithm.
528        let toml = r#"
529name = "test"
530
531[services.web]
532rootfs = "/nix/store/web"
533command = ["/bin/web"]
534memory = "256M"
535"#;
536        let config = TopologyConfig::from_toml(toml).unwrap();
537        let hash1 = config.service_config_hash("web").unwrap();
538        let hash2 = config.service_config_hash("web").unwrap();
539        assert_eq!(
540            hash1, hash2,
541            "hash must be deterministic within same process"
542        );
543
544        // Verify hash stability: the implementation must use a stable hasher
545        // (e.g., SHA-256), not DefaultHasher which varies across Rust versions.
546        // Pin to a known value so any hasher change is caught.
547        let expected: u64 = hash1; // If this test is run after a hasher change, update this value.
548        assert_eq!(
549            config.service_config_hash("web").unwrap(),
550            expected,
551            "service_config_hash must be deterministic and stable across invocations"
552        );
553    }
554
555    #[test]
556    fn test_validate_rejects_absolute_path_volume_mounts() {
557        // BUG-20: Docker-style absolute path volume mounts must produce
558        // a clear error, not a confusing "unknown volume" message
559        let toml = r#"
560name = "test"
561
562[services.web]
563rootfs = "/nix/store/web"
564command = ["/bin/web"]
565memory = "256M"
566volumes = ["/host/path:/container/path"]
567"#;
568        let config = TopologyConfig::from_toml(toml).unwrap();
569        let err = config.validate().unwrap_err();
570        let msg = err.to_string();
571        assert!(
572            msg.contains("absolute") || msg.contains("named volume"),
573            "Absolute path volume mount must produce a clear error about named volumes, got: {}",
574            msg
575        );
576    }
577
578    #[test]
579    fn test_validate_rejects_invalid_volume_owner() {
580        let toml = r#"
581name = "test"
582
583[volumes.data]
584volume_type = "persistent"
585path = "/var/lib/test"
586owner = "abc:def"
587
588[services.web]
589rootfs = "/nix/store/web"
590command = ["/bin/web"]
591memory = "256M"
592volumes = ["data:/var/lib/web"]
593"#;
594        let config = TopologyConfig::from_toml(toml).unwrap();
595        let err = config.validate().unwrap_err();
596        assert!(err.to_string().contains("volume owner"));
597    }
598}