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