1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6use std::path::Path;
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 secrets: Vec<String>,
121
122 #[serde(default)]
124 pub dns: Vec<String>,
125
126 #[serde(default = "default_replicas")]
128 pub replicas: u32,
129
130 #[serde(default = "default_runtime")]
132 pub runtime: String,
133
134 #[serde(default)]
136 pub hooks: Option<crate::security::OciHooks>,
137}
138
139fn default_cpus() -> f64 {
140 1.0
141}
142
143fn default_pids() -> u64 {
144 512
145}
146
147fn default_health_interval() -> u64 {
148 30
149}
150
151fn default_replicas() -> u32 {
152 1
153}
154
155fn default_runtime() -> String {
156 "native".to_string()
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct DependsOn {
162 pub service: String,
164
165 #[serde(default = "default_condition")]
167 pub condition: String,
168}
169
170fn default_condition() -> String {
171 "started".to_string()
172}
173
174impl TopologyConfig {
175 pub fn from_file(path: &Path) -> crate::error::Result<Self> {
177 let content = std::fs::read_to_string(path).map_err(|e| {
178 crate::error::NucleusError::ConfigError(format!(
179 "Failed to read topology file {:?}: {}",
180 path, e
181 ))
182 })?;
183 Self::from_toml(&content)
184 }
185
186 pub fn from_toml(content: &str) -> crate::error::Result<Self> {
188 toml::from_str(content).map_err(|e| {
189 crate::error::NucleusError::ConfigError(format!("Failed to parse topology: {}", e))
190 })
191 }
192
193 pub fn validate(&self) -> crate::error::Result<()> {
195 if self.name.is_empty() {
196 return Err(crate::error::NucleusError::ConfigError(
197 "Topology name cannot be empty".to_string(),
198 ));
199 }
200
201 if self.services.is_empty() {
202 return Err(crate::error::NucleusError::ConfigError(
203 "Topology must have at least one service".to_string(),
204 ));
205 }
206
207 for (name, svc) in &self.services {
209 for dep in &svc.depends_on {
210 if !self.services.contains_key(&dep.service) {
211 return Err(crate::error::NucleusError::ConfigError(format!(
212 "Service '{}' depends on unknown service '{}'",
213 name, dep.service
214 )));
215 }
216 if dep.condition != "started" && dep.condition != "healthy" {
217 return Err(crate::error::NucleusError::ConfigError(format!(
218 "Invalid dependency condition '{}' for service '{}'",
219 dep.condition, name
220 )));
221 }
222 if dep.condition == "healthy" {
223 let dep_service = self.services.get(&dep.service).ok_or_else(|| {
224 crate::error::NucleusError::ConfigError(format!(
225 "Service '{}' depends on unknown service '{}'",
226 name, dep.service
227 ))
228 })?;
229 if dep_service.health_check.is_none() {
230 return Err(crate::error::NucleusError::ConfigError(format!(
231 "Service '{}' depends on '{}' being healthy, but '{}' has no health_check",
232 name, dep.service, dep.service
233 )));
234 }
235 }
236 }
237
238 for net in &svc.networks {
240 if !self.networks.contains_key(net) {
241 return Err(crate::error::NucleusError::ConfigError(format!(
242 "Service '{}' references unknown network '{}'",
243 name, net
244 )));
245 }
246 }
247
248 for vol_mount in &svc.volumes {
250 let vol_name = vol_mount.split(':').next().unwrap_or("");
251 if vol_name.starts_with('/') {
252 return Err(crate::error::NucleusError::ConfigError(format!(
253 "Service '{}' uses absolute host-path volume mount '{}'; topology configs must reference a named volume instead",
254 name, vol_name
255 )));
256 }
257 if !self.volumes.contains_key(vol_name) {
258 return Err(crate::error::NucleusError::ConfigError(format!(
259 "Service '{}' references unknown volume '{}'",
260 name, vol_name
261 )));
262 }
263 }
264 }
265
266 Ok(())
267 }
268
269 pub fn service_config_hash(&self, service_name: &str) -> Option<u64> {
271 self.services.get(service_name).and_then(|svc| {
272 let json = serde_json::to_vec(svc).ok()?;
273 let digest = Sha256::digest(&json);
274 let mut bytes = [0u8; 8];
275 bytes.copy_from_slice(&digest[..8]);
276 Some(u64::from_be_bytes(bytes))
277 })
278 }
279}
280
281impl Default for NetworkDef {
282 fn default() -> Self {
283 Self {
284 subnet: default_subnet(),
285 encrypted: false,
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn test_parse_minimal_topology() {
296 let toml = r#"
297name = "test-stack"
298
299[services.web]
300rootfs = "/nix/store/abc-web"
301command = ["/bin/web-server"]
302memory = "512M"
303"#;
304 let config = TopologyConfig::from_toml(toml).unwrap();
305 assert_eq!(config.name, "test-stack");
306 assert_eq!(config.services.len(), 1);
307 assert!(config.services.contains_key("web"));
308 }
309
310 #[test]
311 fn test_parse_full_topology() {
312 let toml = r#"
313name = "myapp"
314
315[networks.internal]
316subnet = "10.42.0.0/24"
317encrypted = true
318
319[volumes.db-data]
320volume_type = "persistent"
321path = "/var/lib/nucleus/myapp/db"
322owner = "70:70"
323
324[services.postgres]
325rootfs = "/nix/store/abc-postgres"
326command = ["postgres", "-D", "/var/lib/postgresql/data"]
327memory = "2G"
328cpus = 2.0
329networks = ["internal"]
330volumes = ["db-data:/var/lib/postgresql/data"]
331health_check = "pg_isready -U myapp"
332
333[services.web]
334rootfs = "/nix/store/abc-web"
335command = ["/bin/web-server"]
336memory = "512M"
337cpus = 1.0
338networks = ["internal"]
339port_forwards = ["8443:8443"]
340egress_allow = ["10.42.0.0/24"]
341
342[[services.web.depends_on]]
343service = "postgres"
344condition = "healthy"
345"#;
346 let config = TopologyConfig::from_toml(toml).unwrap();
347 assert_eq!(config.name, "myapp");
348 assert_eq!(config.services.len(), 2);
349 assert_eq!(config.networks.len(), 1);
350 assert_eq!(config.volumes.len(), 1);
351 assert!(config.validate().is_ok());
352 }
353
354 #[test]
355 fn test_validate_missing_dependency() {
356 let toml = r#"
357name = "bad"
358
359[services.web]
360rootfs = "/nix/store/abc"
361command = ["/bin/web"]
362memory = "256M"
363
364[[services.web.depends_on]]
365service = "nonexistent"
366"#;
367 let config = TopologyConfig::from_toml(toml).unwrap();
368 assert!(config.validate().is_err());
369 }
370
371 #[test]
372 fn test_validate_healthy_dependency_requires_health_check() {
373 let toml = r#"
374name = "bad"
375
376[services.db]
377rootfs = "/nix/store/db"
378command = ["postgres"]
379memory = "512M"
380
381[services.web]
382rootfs = "/nix/store/web"
383command = ["/bin/web"]
384memory = "256M"
385
386[[services.web.depends_on]]
387service = "db"
388condition = "healthy"
389"#;
390 let config = TopologyConfig::from_toml(toml).unwrap();
391 let err = config.validate().unwrap_err();
392 assert!(err.to_string().contains("health_check"));
393 }
394
395 #[test]
396 fn test_service_config_hash_is_stable_across_invocations() {
397 let toml = r#"
400name = "test"
401
402[services.web]
403rootfs = "/nix/store/web"
404command = ["/bin/web"]
405memory = "256M"
406"#;
407 let config = TopologyConfig::from_toml(toml).unwrap();
408 let hash1 = config.service_config_hash("web").unwrap();
409 let hash2 = config.service_config_hash("web").unwrap();
410 assert_eq!(hash1, hash2, "hash must be deterministic within same process");
411
412 let expected: u64 = hash1; assert_eq!(
417 config.service_config_hash("web").unwrap(),
418 expected,
419 "service_config_hash must be deterministic and stable across invocations"
420 );
421 }
422
423 #[test]
424 fn test_validate_rejects_absolute_path_volume_mounts() {
425 let toml = r#"
428name = "test"
429
430[services.web]
431rootfs = "/nix/store/web"
432command = ["/bin/web"]
433memory = "256M"
434volumes = ["/host/path:/container/path"]
435"#;
436 let config = TopologyConfig::from_toml(toml).unwrap();
437 let err = config.validate().unwrap_err();
438 let msg = err.to_string();
439 assert!(
440 msg.contains("absolute") || msg.contains("named volume"),
441 "Absolute path volume mount must produce a clear error about named volumes, got: {}",
442 msg
443 );
444 }
445}