1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct TopologyConfig {
11 pub name: String,
13
14 #[serde(default)]
16 pub networks: BTreeMap<String, NetworkDef>,
17
18 #[serde(default)]
20 pub volumes: BTreeMap<String, VolumeDef>,
21
22 pub services: BTreeMap<String, ServiceDef>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct NetworkDef {
29 #[serde(default = "default_subnet")]
31 pub subnet: String,
32
33 #[serde(default)]
35 pub encrypted: bool,
36}
37
38fn default_subnet() -> String {
39 "10.42.0.0/24".to_string()
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VolumeDef {
45 #[serde(default = "default_volume_type")]
47 pub volume_type: String,
48
49 pub path: Option<String>,
51
52 pub owner: Option<String>,
54
55 pub size: Option<String>,
57}
58
59fn default_volume_type() -> String {
60 "ephemeral".to_string()
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ServiceDef {
67 pub rootfs: String,
69
70 pub command: Vec<String>,
72
73 pub memory: String,
75
76 #[serde(default = "default_cpus")]
78 pub cpus: f64,
79
80 #[serde(default = "default_pids")]
82 pub pids: u64,
83
84 #[serde(default)]
86 pub networks: Vec<String>,
87
88 #[serde(default)]
90 pub volumes: Vec<String>,
91
92 #[serde(default)]
94 pub depends_on: Vec<DependsOn>,
95
96 pub health_check: Option<String>,
98
99 #[serde(default = "default_health_interval")]
101 pub health_interval: u64,
102
103 #[serde(default)]
105 pub egress_allow: Vec<String>,
106
107 #[serde(default)]
109 pub egress_domains: Vec<String>,
110
111 #[serde(default)]
113 pub egress_tcp_ports: Vec<u16>,
114
115 #[serde(default)]
117 pub egress_udp_ports: Vec<u16>,
118
119 #[serde(default)]
121 pub credential_broker: Option<String>,
122
123 #[serde(default)]
125 pub credential_broker_no_proxy_env: bool,
126
127 #[serde(default)]
129 pub port_forwards: Vec<String>,
130
131 #[serde(default)]
133 pub environment: BTreeMap<String, String>,
134
135 #[serde(default)]
137 pub user: Option<String>,
138
139 #[serde(default)]
141 pub group: Option<String>,
142
143 #[serde(default)]
145 pub additional_groups: Vec<String>,
146
147 #[serde(default)]
149 pub secrets: Vec<String>,
150
151 #[serde(default)]
153 pub dns: Vec<String>,
154
155 #[serde(default = "default_nat_backend")]
157 pub nat_backend: crate::network::NatBackend,
158
159 #[serde(default = "default_replicas")]
161 pub replicas: u32,
162
163 #[serde(default = "default_runtime")]
165 pub runtime: String,
166 }
170
171fn default_cpus() -> f64 {
172 1.0
173}
174
175fn default_pids() -> u64 {
176 512
177}
178
179fn default_health_interval() -> u64 {
180 30
181}
182
183fn default_replicas() -> u32 {
184 1
185}
186
187fn default_nat_backend() -> crate::network::NatBackend {
188 crate::network::NatBackend::Auto
189}
190
191fn default_runtime() -> String {
192 "native".to_string()
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct DependsOn {
198 pub service: String,
200
201 #[serde(default = "default_condition")]
203 pub condition: String,
204}
205
206fn default_condition() -> String {
207 "started".to_string()
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct ServiceVolumeMount {
213 pub volume: String,
215 pub dest: PathBuf,
217 pub read_only: bool,
219}
220
221pub(crate) fn parse_service_volume_mount(spec: &str) -> crate::error::Result<ServiceVolumeMount> {
222 let parts: Vec<&str> = spec.split(':').collect();
223 let (volume, dest, read_only) = match parts.as_slice() {
224 [volume, dest] => (*volume, *dest, false),
225 [volume, dest, mode] if *mode == "ro" => (*volume, *dest, true),
226 [volume, dest, mode] if *mode == "rw" => (*volume, *dest, false),
227 _ => {
228 return Err(crate::error::NucleusError::ConfigError(format!(
229 "Invalid volume mount '{}', expected VOLUME:DEST[:ro|rw]",
230 spec
231 )));
232 }
233 };
234
235 if volume.is_empty() {
236 return Err(crate::error::NucleusError::ConfigError(format!(
237 "Volume mount '{}' must name a topology volume",
238 spec
239 )));
240 }
241
242 let dest = crate::filesystem::normalize_volume_destination(Path::new(dest))?;
243 Ok(ServiceVolumeMount {
244 volume: volume.to_string(),
245 dest,
246 read_only,
247 })
248}
249
250pub(crate) fn parse_volume_owner(owner: &str) -> crate::error::Result<(u32, u32)> {
251 let (uid, gid) = owner.split_once(':').ok_or_else(|| {
252 crate::error::NucleusError::ConfigError(format!(
253 "Invalid volume owner '{}', expected UID:GID",
254 owner
255 ))
256 })?;
257 let uid = uid.parse::<u32>().map_err(|e| {
258 crate::error::NucleusError::ConfigError(format!(
259 "Invalid volume owner UID '{}' in '{}': {}",
260 uid, owner, e
261 ))
262 })?;
263 let gid = gid.parse::<u32>().map_err(|e| {
264 crate::error::NucleusError::ConfigError(format!(
265 "Invalid volume owner GID '{}' in '{}': {}",
266 gid, owner, e
267 ))
268 })?;
269 Ok((uid, gid))
270}
271
272impl TopologyConfig {
273 pub fn from_file(path: &Path) -> crate::error::Result<Self> {
275 let content = std::fs::read_to_string(path).map_err(|e| {
276 crate::error::NucleusError::ConfigError(format!(
277 "Failed to read topology file {:?}: {}",
278 path, e
279 ))
280 })?;
281 Self::from_toml(&content)
282 }
283
284 pub fn from_toml(content: &str) -> crate::error::Result<Self> {
286 toml::from_str(content).map_err(|e| {
287 crate::error::NucleusError::ConfigError(format!("Failed to parse topology: {}", e))
288 })
289 }
290
291 pub fn validate(&self) -> crate::error::Result<()> {
293 if self.name.is_empty() {
294 return Err(crate::error::NucleusError::ConfigError(
295 "Topology name cannot be empty".to_string(),
296 ));
297 }
298
299 crate::container::validate_container_name(&self.name).map_err(|_| {
303 crate::error::NucleusError::ConfigError(format!(
304 "Topology name '{}' contains invalid characters (allowed: a-zA-Z0-9, '-', '_', '.')",
305 self.name
306 ))
307 })?;
308 for service_name in self.services.keys() {
309 crate::container::validate_container_name(service_name).map_err(|_| {
310 crate::error::NucleusError::ConfigError(format!(
311 "Service name '{}' contains invalid characters (allowed: a-zA-Z0-9, '-', '_', '.')",
312 service_name
313 ))
314 })?;
315 }
316
317 if self.services.is_empty() {
318 return Err(crate::error::NucleusError::ConfigError(
319 "Topology must have at least one service".to_string(),
320 ));
321 }
322
323 for (name, volume) in &self.volumes {
324 match volume.volume_type.as_str() {
325 "persistent" => {
326 let path = volume.path.as_ref().ok_or_else(|| {
327 crate::error::NucleusError::ConfigError(format!(
328 "Persistent volume '{}' must define path",
329 name
330 ))
331 })?;
332 if !Path::new(path).is_absolute() {
333 return Err(crate::error::NucleusError::ConfigError(format!(
334 "Persistent volume '{}' path must be absolute: {}",
335 name, path
336 )));
337 }
338 crate::filesystem::validate_bind_mount_source_policy(Path::new(path))?;
339 }
340 "ephemeral" => {
341 if volume.path.is_some() {
342 return Err(crate::error::NucleusError::ConfigError(format!(
343 "Ephemeral volume '{}' must not define path",
344 name
345 )));
346 }
347 }
348 other => {
349 return Err(crate::error::NucleusError::ConfigError(format!(
350 "Volume '{}' has unsupported type '{}'",
351 name, other
352 )));
353 }
354 }
355
356 if let Some(owner) = &volume.owner {
357 parse_volume_owner(owner)?;
358 }
359 }
360
361 for (name, svc) in &self.services {
363 for dep in &svc.depends_on {
364 if !self.services.contains_key(&dep.service) {
365 return Err(crate::error::NucleusError::ConfigError(format!(
366 "Service '{}' depends on unknown service '{}'",
367 name, dep.service
368 )));
369 }
370 if dep.condition != "started" && dep.condition != "healthy" {
371 return Err(crate::error::NucleusError::ConfigError(format!(
372 "Invalid dependency condition '{}' for service '{}'",
373 dep.condition, name
374 )));
375 }
376 if dep.condition == "healthy" {
377 let dep_service = self.services.get(&dep.service).ok_or_else(|| {
378 crate::error::NucleusError::ConfigError(format!(
379 "Service '{}' depends on unknown service '{}'",
380 name, dep.service
381 ))
382 })?;
383 if dep_service.health_check.is_none() {
384 return Err(crate::error::NucleusError::ConfigError(format!(
385 "Service '{}' depends on '{}' being healthy, but '{}' has no health_check",
386 name, dep.service, dep.service
387 )));
388 }
389 }
390 }
391
392 for net in &svc.networks {
394 if !self.networks.contains_key(net) {
395 return Err(crate::error::NucleusError::ConfigError(format!(
396 "Service '{}' references unknown network '{}'",
397 name, net
398 )));
399 }
400 }
401
402 for cidr in &svc.egress_allow {
403 crate::network::validate_egress_cidr(cidr).map_err(|e| {
404 crate::error::NucleusError::ConfigError(format!(
405 "Service '{}' has invalid egress_allow entry '{}': {}",
406 name, cidr, e
407 ))
408 })?;
409 }
410
411 for domain in &svc.egress_domains {
412 crate::network::validate_egress_domain(domain).map_err(|e| {
413 crate::error::NucleusError::ConfigError(format!(
414 "Service '{}' has invalid egress_domains entry '{}': {}",
415 name, domain, e
416 ))
417 })?;
418 }
419
420 if let Some(endpoint) = &svc.credential_broker {
421 let broker = crate::network::CredentialBrokerConfig::parse_endpoint(endpoint)
422 .map_err(|e| {
423 crate::error::NucleusError::ConfigError(format!(
424 "Service '{}' has invalid credential_broker '{}': {}",
425 name, endpoint, e
426 ))
427 })?;
428 if svc.networks.is_empty() {
429 return Err(crate::error::NucleusError::ConfigError(format!(
430 "Service '{}' uses credential_broker but has no bridge network",
431 name
432 )));
433 }
434 let network_name = &svc.networks[0];
435 let network = self.networks.get(network_name).ok_or_else(|| {
436 crate::error::NucleusError::ConfigError(format!(
437 "Service '{}' references unknown network '{}'",
438 name, network_name
439 ))
440 })?;
441 let bridge = crate::network::BridgeConfig {
442 subnet: network.subnet.clone(),
443 ..crate::network::BridgeConfig::default()
444 };
445 broker.validate_for_bridge(&bridge).map_err(|e| {
446 crate::error::NucleusError::ConfigError(format!(
447 "Service '{}' has invalid credential_broker '{}': {}",
448 name, endpoint, e
449 ))
450 })?;
451 if !svc.egress_allow.is_empty()
452 || !svc.egress_domains.is_empty()
453 || !svc.egress_tcp_ports.is_empty()
454 || !svc.egress_udp_ports.is_empty()
455 {
456 return Err(crate::error::NucleusError::ConfigError(format!(
457 "Service '{}' cannot combine credential_broker with raw egress allowlists",
458 name
459 )));
460 }
461 } else if svc.credential_broker_no_proxy_env {
462 return Err(crate::error::NucleusError::ConfigError(format!(
463 "Service '{}' sets credential_broker_no_proxy_env without credential_broker",
464 name
465 )));
466 }
467
468 for vol_mount in &svc.volumes {
470 let parsed = parse_service_volume_mount(vol_mount)?;
471 if parsed.volume.starts_with('/') {
472 return Err(crate::error::NucleusError::ConfigError(format!(
473 "Service '{}' uses absolute host-path volume mount '{}'; topology configs must reference a named volume instead",
474 name, parsed.volume
475 )));
476 }
477 if !self.volumes.contains_key(&parsed.volume) {
478 return Err(crate::error::NucleusError::ConfigError(format!(
479 "Service '{}' references unknown volume '{}'",
480 name, parsed.volume
481 )));
482 }
483 }
484 }
485
486 Ok(())
487 }
488
489 pub fn service_config_hash(&self, service_name: &str) -> Option<u64> {
491 self.services.get(service_name).and_then(|svc| {
492 let json = serde_json::to_vec(svc).ok()?;
493 let digest = Sha256::digest(&json);
494 let mut bytes = [0u8; 8];
495 bytes.copy_from_slice(&digest[..8]);
496 Some(u64::from_be_bytes(bytes))
497 })
498 }
499}
500
501impl Default for NetworkDef {
502 fn default() -> Self {
503 Self {
504 subnet: default_subnet(),
505 encrypted: false,
506 }
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn test_parse_minimal_topology() {
516 let toml = r#"
517name = "test-stack"
518
519[services.web]
520rootfs = "/nix/store/abc-web"
521command = ["/bin/web-server"]
522memory = "512M"
523"#;
524 let config = TopologyConfig::from_toml(toml).unwrap();
525 assert_eq!(config.name, "test-stack");
526 assert_eq!(config.services.len(), 1);
527 assert!(config.services.contains_key("web"));
528 }
529
530 #[test]
531 fn test_parse_full_topology() {
532 let toml = r#"
533name = "myapp"
534
535[networks.internal]
536subnet = "10.42.0.0/24"
537encrypted = true
538
539[volumes.db-data]
540volume_type = "persistent"
541path = "/var/lib/nucleus/myapp/db"
542owner = "70:70"
543
544[services.postgres]
545rootfs = "/nix/store/abc-postgres"
546command = ["postgres", "-D", "/var/lib/postgresql/data"]
547memory = "2G"
548cpus = 2.0
549networks = ["internal"]
550volumes = ["db-data:/var/lib/postgresql/data"]
551health_check = "pg_isready -U myapp"
552
553[services.web]
554rootfs = "/nix/store/abc-web"
555command = ["/bin/web-server"]
556memory = "512M"
557cpus = 1.0
558networks = ["internal"]
559nat_backend = "userspace"
560port_forwards = ["8443:8443"]
561egress_allow = ["10.42.0.0/24"]
562egress_domains = ["api.example.com"]
563egress_udp_ports = [53]
564
565[[services.web.depends_on]]
566service = "postgres"
567condition = "healthy"
568"#;
569 let config = TopologyConfig::from_toml(toml).unwrap();
570 assert_eq!(config.name, "myapp");
571 assert_eq!(config.services.len(), 2);
572 assert_eq!(config.networks.len(), 1);
573 assert_eq!(config.volumes.len(), 1);
574 assert_eq!(
575 config.services["web"].nat_backend,
576 crate::network::NatBackend::Userspace
577 );
578 assert_eq!(
579 config.services["web"].egress_domains,
580 vec!["api.example.com"]
581 );
582 assert_eq!(config.services["web"].egress_udp_ports, vec![53]);
583 assert!(config.validate().is_ok());
584 }
585
586 #[test]
587 fn test_validate_rejects_invalid_egress_domain() {
588 let toml = r#"
589name = "bad"
590
591[services.web]
592rootfs = "/nix/store/web"
593command = ["/bin/web"]
594memory = "256M"
595egress_domains = ["*.example.com"]
596"#;
597 let config = TopologyConfig::from_toml(toml).unwrap();
598 let err = config.validate().unwrap_err();
599 assert!(err.to_string().contains("egress_domains"));
600 }
601
602 #[test]
603 fn test_validate_accepts_credential_broker_service() {
604 let toml = r#"
605name = "brokered"
606
607[networks.internal]
608driver = "bridge"
609
610[services.web]
611rootfs = "/nix/store/web"
612command = ["/bin/web"]
613memory = "256M"
614networks = ["internal"]
615credential_broker = "10.42.0.1:8080"
616credential_broker_no_proxy_env = true
617"#;
618 let config = TopologyConfig::from_toml(toml).unwrap();
619 assert!(config.validate().is_ok());
620 }
621
622 #[test]
623 fn test_validate_rejects_credential_broker_with_raw_egress() {
624 let toml = r#"
625name = "bad"
626
627[networks.internal]
628driver = "bridge"
629
630[services.web]
631rootfs = "/nix/store/web"
632command = ["/bin/web"]
633memory = "256M"
634networks = ["internal"]
635credential_broker = "10.42.0.1:8080"
636egress_domains = ["api.example.com"]
637"#;
638 let config = TopologyConfig::from_toml(toml).unwrap();
639 let err = config.validate().unwrap_err();
640 assert!(err.to_string().contains("credential_broker"));
641 }
642
643 #[test]
644 fn test_validate_rejects_credential_broker_with_udp_egress() {
645 let toml = r#"
646name = "bad"
647
648[networks.internal]
649driver = "bridge"
650
651[services.web]
652rootfs = "/nix/store/web"
653command = ["/bin/web"]
654memory = "256M"
655networks = ["internal"]
656credential_broker = "10.42.0.1:8080"
657egress_udp_ports = [53]
658"#;
659 let config = TopologyConfig::from_toml(toml).unwrap();
660 let err = config.validate().unwrap_err();
661 assert!(err.to_string().contains("credential_broker"));
662 }
663
664 #[test]
665 fn test_validate_rejects_credential_broker_outside_bridge_gateway() {
666 let toml = r#"
667name = "bad"
668
669[networks.internal]
670driver = "bridge"
671
672[services.web]
673rootfs = "/nix/store/web"
674command = ["/bin/web"]
675memory = "256M"
676networks = ["internal"]
677credential_broker = "169.254.169.254:80"
678"#;
679 let config = TopologyConfig::from_toml(toml).unwrap();
680 let err = config.validate().unwrap_err();
681 assert!(err
682 .to_string()
683 .contains("host-side bridge address 10.42.0.1"));
684 }
685
686 #[test]
687 fn test_validate_accepts_credential_broker_custom_bridge_gateway() {
688 let toml = r#"
689name = "brokered"
690
691[networks.internal]
692driver = "bridge"
693subnet = "10.0.42.0/24"
694
695[services.web]
696rootfs = "/nix/store/web"
697command = ["/bin/web"]
698memory = "256M"
699networks = ["internal"]
700credential_broker = "10.0.42.1:8080"
701"#;
702 let config = TopologyConfig::from_toml(toml).unwrap();
703 assert!(config.validate().is_ok());
704 }
705
706 #[test]
707 fn test_nat_backend_defaults_to_auto() {
708 let toml = r#"
709name = "test-stack"
710
711[services.web]
712rootfs = "/nix/store/abc-web"
713command = ["/bin/web-server"]
714memory = "512M"
715"#;
716 let config = TopologyConfig::from_toml(toml).unwrap();
717 assert_eq!(
718 config.services["web"].nat_backend,
719 crate::network::NatBackend::Auto
720 );
721 }
722
723 #[test]
724 fn test_validate_missing_dependency() {
725 let toml = r#"
726name = "bad"
727
728[services.web]
729rootfs = "/nix/store/abc"
730command = ["/bin/web"]
731memory = "256M"
732
733[[services.web.depends_on]]
734service = "nonexistent"
735"#;
736 let config = TopologyConfig::from_toml(toml).unwrap();
737 assert!(config.validate().is_err());
738 }
739
740 #[test]
741 fn test_validate_healthy_dependency_requires_health_check() {
742 let toml = r#"
743name = "bad"
744
745[services.db]
746rootfs = "/nix/store/db"
747command = ["postgres"]
748memory = "512M"
749
750[services.web]
751rootfs = "/nix/store/web"
752command = ["/bin/web"]
753memory = "256M"
754
755[[services.web.depends_on]]
756service = "db"
757condition = "healthy"
758"#;
759 let config = TopologyConfig::from_toml(toml).unwrap();
760 let err = config.validate().unwrap_err();
761 assert!(err.to_string().contains("health_check"));
762 }
763
764 #[test]
765 fn test_service_config_hash_is_stable_across_invocations() {
766 let toml = r#"
769name = "test"
770
771[services.web]
772rootfs = "/nix/store/web"
773command = ["/bin/web"]
774memory = "256M"
775"#;
776 let config = TopologyConfig::from_toml(toml).unwrap();
777 let hash1 = config.service_config_hash("web").unwrap();
778 let hash2 = config.service_config_hash("web").unwrap();
779 assert_eq!(
780 hash1, hash2,
781 "hash must be deterministic within same process"
782 );
783
784 let expected: u64 = hash1; assert_eq!(
789 config.service_config_hash("web").unwrap(),
790 expected,
791 "service_config_hash must be deterministic and stable across invocations"
792 );
793 }
794
795 #[test]
796 fn test_validate_rejects_absolute_path_volume_mounts() {
797 let toml = r#"
800name = "test"
801
802[services.web]
803rootfs = "/nix/store/web"
804command = ["/bin/web"]
805memory = "256M"
806volumes = ["/host/path:/container/path"]
807"#;
808 let config = TopologyConfig::from_toml(toml).unwrap();
809 let err = config.validate().unwrap_err();
810 let msg = err.to_string();
811 assert!(
812 msg.contains("absolute") || msg.contains("named volume"),
813 "Absolute path volume mount must produce a clear error about named volumes, got: {}",
814 msg
815 );
816 }
817
818 #[test]
819 fn test_validate_rejects_sensitive_persistent_volume_paths() {
820 let toml = r#"
821name = "test"
822
823[volumes.host-etc]
824volume_type = "persistent"
825path = "/etc/nucleus"
826
827[services.web]
828rootfs = "/nix/store/web"
829command = ["/bin/web"]
830memory = "256M"
831volumes = ["host-etc:/var/lib/web"]
832"#;
833 let config = TopologyConfig::from_toml(toml).unwrap();
834 let err = config.validate().unwrap_err();
835 assert!(err.to_string().contains("sensitive host path"));
836 }
837
838 #[test]
839 fn test_validate_rejects_reserved_volume_destinations() {
840 let toml = r#"
841name = "test"
842
843[volumes.data]
844volume_type = "ephemeral"
845size = "64M"
846
847[services.web]
848rootfs = "/nix/store/web"
849command = ["/bin/web"]
850memory = "256M"
851volumes = ["data:/etc"]
852"#;
853 let config = TopologyConfig::from_toml(toml).unwrap();
854 let err = config.validate().unwrap_err();
855 assert!(err.to_string().contains("reserved"));
856 }
857
858 #[test]
859 fn test_validate_rejects_invalid_volume_owner() {
860 let toml = r#"
861name = "test"
862
863[volumes.data]
864volume_type = "persistent"
865path = "/var/lib/test"
866owner = "abc:def"
867
868[services.web]
869rootfs = "/nix/store/web"
870command = ["/bin/web"]
871memory = "256M"
872volumes = ["data:/var/lib/web"]
873"#;
874 let config = TopologyConfig::from_toml(toml).unwrap();
875 let err = config.validate().unwrap_err();
876 assert!(err.to_string().contains("volume owner"));
877 }
878
879 #[test]
880 fn test_topology_rejects_service_oci_hooks() {
881 let toml = r#"
882name = "test"
883
884[services.web]
885rootfs = "/nix/store/web"
886command = ["/bin/web"]
887memory = "256M"
888
889[services.web.hooks]
890poststart = [
891 { path = "/bin/sh", args = ["sh", "-c", "id > /tmp/nucleus-owned"] }
892]
893"#;
894 let err = TopologyConfig::from_toml(toml).unwrap_err();
895 let msg = err.to_string();
896 assert!(
897 msg.contains("unknown field `hooks`") || msg.contains("unknown field 'hooks'"),
898 "topology service hooks must be rejected at parse time, got: {}",
899 msg
900 );
901 }
902}