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_replicas")]
140 pub replicas: u32,
141
142 #[serde(default = "default_runtime")]
144 pub runtime: String,
145
146 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct DependsOn {
174 pub service: String,
176
177 #[serde(default = "default_condition")]
179 pub condition: String,
180}
181
182fn default_condition() -> String {
183 "started".to_string()
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct ServiceVolumeMount {
189 pub volume: String,
191 pub dest: PathBuf,
193 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 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 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 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 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 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 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 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 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 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 let expected: u64 = hash1; 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 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}