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 if self.services.is_empty() {
276 return Err(crate::error::NucleusError::ConfigError(
277 "Topology must have at least one service".to_string(),
278 ));
279 }
280
281 for (name, volume) in &self.volumes {
282 match volume.volume_type.as_str() {
283 "persistent" => {
284 let path = volume.path.as_ref().ok_or_else(|| {
285 crate::error::NucleusError::ConfigError(format!(
286 "Persistent volume '{}' must define path",
287 name
288 ))
289 })?;
290 if !Path::new(path).is_absolute() {
291 return Err(crate::error::NucleusError::ConfigError(format!(
292 "Persistent volume '{}' path must be absolute: {}",
293 name, path
294 )));
295 }
296 }
297 "ephemeral" => {
298 if volume.path.is_some() {
299 return Err(crate::error::NucleusError::ConfigError(format!(
300 "Ephemeral volume '{}' must not define path",
301 name
302 )));
303 }
304 }
305 other => {
306 return Err(crate::error::NucleusError::ConfigError(format!(
307 "Volume '{}' has unsupported type '{}'",
308 name, other
309 )));
310 }
311 }
312
313 if let Some(owner) = &volume.owner {
314 parse_volume_owner(owner)?;
315 }
316 }
317
318 for (name, svc) in &self.services {
320 for dep in &svc.depends_on {
321 if !self.services.contains_key(&dep.service) {
322 return Err(crate::error::NucleusError::ConfigError(format!(
323 "Service '{}' depends on unknown service '{}'",
324 name, dep.service
325 )));
326 }
327 if dep.condition != "started" && dep.condition != "healthy" {
328 return Err(crate::error::NucleusError::ConfigError(format!(
329 "Invalid dependency condition '{}' for service '{}'",
330 dep.condition, name
331 )));
332 }
333 if dep.condition == "healthy" {
334 let dep_service = self.services.get(&dep.service).ok_or_else(|| {
335 crate::error::NucleusError::ConfigError(format!(
336 "Service '{}' depends on unknown service '{}'",
337 name, dep.service
338 ))
339 })?;
340 if dep_service.health_check.is_none() {
341 return Err(crate::error::NucleusError::ConfigError(format!(
342 "Service '{}' depends on '{}' being healthy, but '{}' has no health_check",
343 name, dep.service, dep.service
344 )));
345 }
346 }
347 }
348
349 for net in &svc.networks {
351 if !self.networks.contains_key(net) {
352 return Err(crate::error::NucleusError::ConfigError(format!(
353 "Service '{}' references unknown network '{}'",
354 name, net
355 )));
356 }
357 }
358
359 for vol_mount in &svc.volumes {
361 let parsed = parse_service_volume_mount(vol_mount)?;
362 if parsed.volume.starts_with('/') {
363 return Err(crate::error::NucleusError::ConfigError(format!(
364 "Service '{}' uses absolute host-path volume mount '{}'; topology configs must reference a named volume instead",
365 name, parsed.volume
366 )));
367 }
368 if !self.volumes.contains_key(&parsed.volume) {
369 return Err(crate::error::NucleusError::ConfigError(format!(
370 "Service '{}' references unknown volume '{}'",
371 name, parsed.volume
372 )));
373 }
374 }
375 }
376
377 Ok(())
378 }
379
380 pub fn service_config_hash(&self, service_name: &str) -> Option<u64> {
382 self.services.get(service_name).and_then(|svc| {
383 let json = serde_json::to_vec(svc).ok()?;
384 let digest = Sha256::digest(&json);
385 let mut bytes = [0u8; 8];
386 bytes.copy_from_slice(&digest[..8]);
387 Some(u64::from_be_bytes(bytes))
388 })
389 }
390}
391
392impl Default for NetworkDef {
393 fn default() -> Self {
394 Self {
395 subnet: default_subnet(),
396 encrypted: false,
397 }
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn test_parse_minimal_topology() {
407 let toml = r#"
408name = "test-stack"
409
410[services.web]
411rootfs = "/nix/store/abc-web"
412command = ["/bin/web-server"]
413memory = "512M"
414"#;
415 let config = TopologyConfig::from_toml(toml).unwrap();
416 assert_eq!(config.name, "test-stack");
417 assert_eq!(config.services.len(), 1);
418 assert!(config.services.contains_key("web"));
419 }
420
421 #[test]
422 fn test_parse_full_topology() {
423 let toml = r#"
424name = "myapp"
425
426[networks.internal]
427subnet = "10.42.0.0/24"
428encrypted = true
429
430[volumes.db-data]
431volume_type = "persistent"
432path = "/var/lib/nucleus/myapp/db"
433owner = "70:70"
434
435[services.postgres]
436rootfs = "/nix/store/abc-postgres"
437command = ["postgres", "-D", "/var/lib/postgresql/data"]
438memory = "2G"
439cpus = 2.0
440networks = ["internal"]
441volumes = ["db-data:/var/lib/postgresql/data"]
442health_check = "pg_isready -U myapp"
443
444[services.web]
445rootfs = "/nix/store/abc-web"
446command = ["/bin/web-server"]
447memory = "512M"
448cpus = 1.0
449networks = ["internal"]
450port_forwards = ["8443:8443"]
451egress_allow = ["10.42.0.0/24"]
452
453[[services.web.depends_on]]
454service = "postgres"
455condition = "healthy"
456"#;
457 let config = TopologyConfig::from_toml(toml).unwrap();
458 assert_eq!(config.name, "myapp");
459 assert_eq!(config.services.len(), 2);
460 assert_eq!(config.networks.len(), 1);
461 assert_eq!(config.volumes.len(), 1);
462 assert!(config.validate().is_ok());
463 }
464
465 #[test]
466 fn test_validate_missing_dependency() {
467 let toml = r#"
468name = "bad"
469
470[services.web]
471rootfs = "/nix/store/abc"
472command = ["/bin/web"]
473memory = "256M"
474
475[[services.web.depends_on]]
476service = "nonexistent"
477"#;
478 let config = TopologyConfig::from_toml(toml).unwrap();
479 assert!(config.validate().is_err());
480 }
481
482 #[test]
483 fn test_validate_healthy_dependency_requires_health_check() {
484 let toml = r#"
485name = "bad"
486
487[services.db]
488rootfs = "/nix/store/db"
489command = ["postgres"]
490memory = "512M"
491
492[services.web]
493rootfs = "/nix/store/web"
494command = ["/bin/web"]
495memory = "256M"
496
497[[services.web.depends_on]]
498service = "db"
499condition = "healthy"
500"#;
501 let config = TopologyConfig::from_toml(toml).unwrap();
502 let err = config.validate().unwrap_err();
503 assert!(err.to_string().contains("health_check"));
504 }
505
506 #[test]
507 fn test_service_config_hash_is_stable_across_invocations() {
508 let toml = r#"
511name = "test"
512
513[services.web]
514rootfs = "/nix/store/web"
515command = ["/bin/web"]
516memory = "256M"
517"#;
518 let config = TopologyConfig::from_toml(toml).unwrap();
519 let hash1 = config.service_config_hash("web").unwrap();
520 let hash2 = config.service_config_hash("web").unwrap();
521 assert_eq!(
522 hash1, hash2,
523 "hash must be deterministic within same process"
524 );
525
526 let expected: u64 = hash1; assert_eq!(
531 config.service_config_hash("web").unwrap(),
532 expected,
533 "service_config_hash must be deterministic and stable across invocations"
534 );
535 }
536
537 #[test]
538 fn test_validate_rejects_absolute_path_volume_mounts() {
539 let toml = r#"
542name = "test"
543
544[services.web]
545rootfs = "/nix/store/web"
546command = ["/bin/web"]
547memory = "256M"
548volumes = ["/host/path:/container/path"]
549"#;
550 let config = TopologyConfig::from_toml(toml).unwrap();
551 let err = config.validate().unwrap_err();
552 let msg = err.to_string();
553 assert!(
554 msg.contains("absolute") || msg.contains("named volume"),
555 "Absolute path volume mount must produce a clear error about named volumes, got: {}",
556 msg
557 );
558 }
559
560 #[test]
561 fn test_validate_rejects_invalid_volume_owner() {
562 let toml = r#"
563name = "test"
564
565[volumes.data]
566volume_type = "persistent"
567path = "/var/lib/test"
568owner = "abc:def"
569
570[services.web]
571rootfs = "/nix/store/web"
572command = ["/bin/web"]
573memory = "256M"
574volumes = ["data:/var/lib/web"]
575"#;
576 let config = TopologyConfig::from_toml(toml).unwrap();
577 let err = config.validate().unwrap_err();
578 assert!(err.to_string().contains("volume owner"));
579 }
580}