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)]
65pub struct ServiceDef {
66 pub rootfs: String,
68
69 pub command: Vec<String>,
71
72 pub memory: String,
74
75 #[serde(default = "default_cpus")]
77 pub cpus: f64,
78
79 #[serde(default = "default_pids")]
81 pub pids: u64,
82
83 #[serde(default)]
85 pub networks: Vec<String>,
86
87 #[serde(default)]
89 pub volumes: Vec<String>,
90
91 #[serde(default)]
93 pub depends_on: Vec<DependsOn>,
94
95 pub health_check: Option<String>,
97
98 #[serde(default = "default_health_interval")]
100 pub health_interval: u64,
101
102 #[serde(default)]
104 pub egress_allow: Vec<String>,
105
106 #[serde(default)]
108 pub egress_tcp_ports: Vec<u16>,
109
110 #[serde(default)]
112 pub port_forwards: Vec<String>,
113
114 #[serde(default)]
116 pub environment: BTreeMap<String, String>,
117
118 #[serde(default)]
120 pub user: Option<String>,
121
122 #[serde(default)]
124 pub group: Option<String>,
125
126 #[serde(default)]
128 pub additional_groups: Vec<String>,
129
130 #[serde(default)]
132 pub secrets: Vec<String>,
133
134 #[serde(default)]
136 pub dns: Vec<String>,
137
138 #[serde(default = "default_nat_backend")]
140 pub nat_backend: crate::network::NatBackend,
141
142 #[serde(default = "default_replicas")]
144 pub replicas: u32,
145
146 #[serde(default = "default_runtime")]
148 pub runtime: String,
149
150 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct DependsOn {
182 pub service: String,
184
185 #[serde(default = "default_condition")]
187 pub condition: String,
188}
189
190fn default_condition() -> String {
191 "started".to_string()
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct ServiceVolumeMount {
197 pub volume: String,
199 pub dest: PathBuf,
201 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 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 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 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 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 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 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 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 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 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 let expected: u64 = hash1; 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 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}