use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopologyConfig {
pub name: String,
#[serde(default)]
pub networks: BTreeMap<String, NetworkDef>,
#[serde(default)]
pub volumes: BTreeMap<String, VolumeDef>,
pub services: BTreeMap<String, ServiceDef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkDef {
#[serde(default = "default_subnet")]
pub subnet: String,
#[serde(default)]
pub encrypted: bool,
}
fn default_subnet() -> String {
"10.42.0.0/24".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeDef {
#[serde(default = "default_volume_type")]
pub volume_type: String,
pub path: Option<String>,
pub owner: Option<String>,
pub size: Option<String>,
}
fn default_volume_type() -> String {
"ephemeral".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDef {
pub rootfs: String,
pub command: Vec<String>,
pub memory: String,
#[serde(default = "default_cpus")]
pub cpus: f64,
#[serde(default = "default_pids")]
pub pids: u64,
#[serde(default)]
pub networks: Vec<String>,
#[serde(default)]
pub volumes: Vec<String>,
#[serde(default)]
pub depends_on: Vec<DependsOn>,
pub health_check: Option<String>,
#[serde(default = "default_health_interval")]
pub health_interval: u64,
#[serde(default)]
pub egress_allow: Vec<String>,
#[serde(default)]
pub egress_tcp_ports: Vec<u16>,
#[serde(default)]
pub port_forwards: Vec<String>,
#[serde(default)]
pub environment: BTreeMap<String, String>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub additional_groups: Vec<String>,
#[serde(default)]
pub secrets: Vec<String>,
#[serde(default)]
pub dns: Vec<String>,
#[serde(default = "default_replicas")]
pub replicas: u32,
#[serde(default = "default_runtime")]
pub runtime: String,
#[serde(default)]
pub hooks: Option<crate::security::OciHooks>,
}
fn default_cpus() -> f64 {
1.0
}
fn default_pids() -> u64 {
512
}
fn default_health_interval() -> u64 {
30
}
fn default_replicas() -> u32 {
1
}
fn default_runtime() -> String {
"native".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependsOn {
pub service: String,
#[serde(default = "default_condition")]
pub condition: String,
}
fn default_condition() -> String {
"started".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceVolumeMount {
pub volume: String,
pub dest: PathBuf,
pub read_only: bool,
}
pub(crate) fn parse_service_volume_mount(spec: &str) -> crate::error::Result<ServiceVolumeMount> {
let parts: Vec<&str> = spec.split(':').collect();
let (volume, dest, read_only) = match parts.as_slice() {
[volume, dest] => (*volume, *dest, false),
[volume, dest, mode] if *mode == "ro" => (*volume, *dest, true),
[volume, dest, mode] if *mode == "rw" => (*volume, *dest, false),
_ => {
return Err(crate::error::NucleusError::ConfigError(format!(
"Invalid volume mount '{}', expected VOLUME:DEST[:ro|rw]",
spec
)));
}
};
if volume.is_empty() {
return Err(crate::error::NucleusError::ConfigError(format!(
"Volume mount '{}' must name a topology volume",
spec
)));
}
let dest = crate::filesystem::normalize_container_destination(Path::new(dest))?;
Ok(ServiceVolumeMount {
volume: volume.to_string(),
dest,
read_only,
})
}
pub(crate) fn parse_volume_owner(owner: &str) -> crate::error::Result<(u32, u32)> {
let (uid, gid) = owner.split_once(':').ok_or_else(|| {
crate::error::NucleusError::ConfigError(format!(
"Invalid volume owner '{}', expected UID:GID",
owner
))
})?;
let uid = uid.parse::<u32>().map_err(|e| {
crate::error::NucleusError::ConfigError(format!(
"Invalid volume owner UID '{}' in '{}': {}",
uid, owner, e
))
})?;
let gid = gid.parse::<u32>().map_err(|e| {
crate::error::NucleusError::ConfigError(format!(
"Invalid volume owner GID '{}' in '{}': {}",
gid, owner, e
))
})?;
Ok((uid, gid))
}
impl TopologyConfig {
pub fn from_file(path: &Path) -> crate::error::Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
crate::error::NucleusError::ConfigError(format!(
"Failed to read topology file {:?}: {}",
path, e
))
})?;
Self::from_toml(&content)
}
pub fn from_toml(content: &str) -> crate::error::Result<Self> {
toml::from_str(content).map_err(|e| {
crate::error::NucleusError::ConfigError(format!("Failed to parse topology: {}", e))
})
}
pub fn validate(&self) -> crate::error::Result<()> {
if self.name.is_empty() {
return Err(crate::error::NucleusError::ConfigError(
"Topology name cannot be empty".to_string(),
));
}
if self.services.is_empty() {
return Err(crate::error::NucleusError::ConfigError(
"Topology must have at least one service".to_string(),
));
}
for (name, volume) in &self.volumes {
match volume.volume_type.as_str() {
"persistent" => {
let path = volume.path.as_ref().ok_or_else(|| {
crate::error::NucleusError::ConfigError(format!(
"Persistent volume '{}' must define path",
name
))
})?;
if !Path::new(path).is_absolute() {
return Err(crate::error::NucleusError::ConfigError(format!(
"Persistent volume '{}' path must be absolute: {}",
name, path
)));
}
}
"ephemeral" => {
if volume.path.is_some() {
return Err(crate::error::NucleusError::ConfigError(format!(
"Ephemeral volume '{}' must not define path",
name
)));
}
}
other => {
return Err(crate::error::NucleusError::ConfigError(format!(
"Volume '{}' has unsupported type '{}'",
name, other
)));
}
}
if let Some(owner) = &volume.owner {
parse_volume_owner(owner)?;
}
}
for (name, svc) in &self.services {
for dep in &svc.depends_on {
if !self.services.contains_key(&dep.service) {
return Err(crate::error::NucleusError::ConfigError(format!(
"Service '{}' depends on unknown service '{}'",
name, dep.service
)));
}
if dep.condition != "started" && dep.condition != "healthy" {
return Err(crate::error::NucleusError::ConfigError(format!(
"Invalid dependency condition '{}' for service '{}'",
dep.condition, name
)));
}
if dep.condition == "healthy" {
let dep_service = self.services.get(&dep.service).ok_or_else(|| {
crate::error::NucleusError::ConfigError(format!(
"Service '{}' depends on unknown service '{}'",
name, dep.service
))
})?;
if dep_service.health_check.is_none() {
return Err(crate::error::NucleusError::ConfigError(format!(
"Service '{}' depends on '{}' being healthy, but '{}' has no health_check",
name, dep.service, dep.service
)));
}
}
}
for net in &svc.networks {
if !self.networks.contains_key(net) {
return Err(crate::error::NucleusError::ConfigError(format!(
"Service '{}' references unknown network '{}'",
name, net
)));
}
}
for vol_mount in &svc.volumes {
let parsed = parse_service_volume_mount(vol_mount)?;
if parsed.volume.starts_with('/') {
return Err(crate::error::NucleusError::ConfigError(format!(
"Service '{}' uses absolute host-path volume mount '{}'; topology configs must reference a named volume instead",
name, parsed.volume
)));
}
if !self.volumes.contains_key(&parsed.volume) {
return Err(crate::error::NucleusError::ConfigError(format!(
"Service '{}' references unknown volume '{}'",
name, parsed.volume
)));
}
}
}
Ok(())
}
pub fn service_config_hash(&self, service_name: &str) -> Option<u64> {
self.services.get(service_name).and_then(|svc| {
let json = serde_json::to_vec(svc).ok()?;
let digest = Sha256::digest(&json);
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&digest[..8]);
Some(u64::from_be_bytes(bytes))
})
}
}
impl Default for NetworkDef {
fn default() -> Self {
Self {
subnet: default_subnet(),
encrypted: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_topology() {
let toml = r#"
name = "test-stack"
[services.web]
rootfs = "/nix/store/abc-web"
command = ["/bin/web-server"]
memory = "512M"
"#;
let config = TopologyConfig::from_toml(toml).unwrap();
assert_eq!(config.name, "test-stack");
assert_eq!(config.services.len(), 1);
assert!(config.services.contains_key("web"));
}
#[test]
fn test_parse_full_topology() {
let toml = r#"
name = "myapp"
[networks.internal]
subnet = "10.42.0.0/24"
encrypted = true
[volumes.db-data]
volume_type = "persistent"
path = "/var/lib/nucleus/myapp/db"
owner = "70:70"
[services.postgres]
rootfs = "/nix/store/abc-postgres"
command = ["postgres", "-D", "/var/lib/postgresql/data"]
memory = "2G"
cpus = 2.0
networks = ["internal"]
volumes = ["db-data:/var/lib/postgresql/data"]
health_check = "pg_isready -U myapp"
[services.web]
rootfs = "/nix/store/abc-web"
command = ["/bin/web-server"]
memory = "512M"
cpus = 1.0
networks = ["internal"]
port_forwards = ["8443:8443"]
egress_allow = ["10.42.0.0/24"]
[[services.web.depends_on]]
service = "postgres"
condition = "healthy"
"#;
let config = TopologyConfig::from_toml(toml).unwrap();
assert_eq!(config.name, "myapp");
assert_eq!(config.services.len(), 2);
assert_eq!(config.networks.len(), 1);
assert_eq!(config.volumes.len(), 1);
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_missing_dependency() {
let toml = r#"
name = "bad"
[services.web]
rootfs = "/nix/store/abc"
command = ["/bin/web"]
memory = "256M"
[[services.web.depends_on]]
service = "nonexistent"
"#;
let config = TopologyConfig::from_toml(toml).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_validate_healthy_dependency_requires_health_check() {
let toml = r#"
name = "bad"
[services.db]
rootfs = "/nix/store/db"
command = ["postgres"]
memory = "512M"
[services.web]
rootfs = "/nix/store/web"
command = ["/bin/web"]
memory = "256M"
[[services.web.depends_on]]
service = "db"
condition = "healthy"
"#;
let config = TopologyConfig::from_toml(toml).unwrap();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("health_check"));
}
#[test]
fn test_service_config_hash_is_stable_across_invocations() {
let toml = r#"
name = "test"
[services.web]
rootfs = "/nix/store/web"
command = ["/bin/web"]
memory = "256M"
"#;
let config = TopologyConfig::from_toml(toml).unwrap();
let hash1 = config.service_config_hash("web").unwrap();
let hash2 = config.service_config_hash("web").unwrap();
assert_eq!(
hash1, hash2,
"hash must be deterministic within same process"
);
let expected: u64 = hash1; assert_eq!(
config.service_config_hash("web").unwrap(),
expected,
"service_config_hash must be deterministic and stable across invocations"
);
}
#[test]
fn test_validate_rejects_absolute_path_volume_mounts() {
let toml = r#"
name = "test"
[services.web]
rootfs = "/nix/store/web"
command = ["/bin/web"]
memory = "256M"
volumes = ["/host/path:/container/path"]
"#;
let config = TopologyConfig::from_toml(toml).unwrap();
let err = config.validate().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("absolute") || msg.contains("named volume"),
"Absolute path volume mount must produce a clear error about named volumes, got: {}",
msg
);
}
#[test]
fn test_validate_rejects_invalid_volume_owner() {
let toml = r#"
name = "test"
[volumes.data]
volume_type = "persistent"
path = "/var/lib/test"
owner = "abc:def"
[services.web]
rootfs = "/nix/store/web"
command = ["/bin/web"]
memory = "256M"
volumes = ["data:/var/lib/web"]
"#;
let config = TopologyConfig::from_toml(toml).unwrap();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("volume owner"));
}
}