use std::collections::{HashMap, HashSet};
use typed_path::{Utf8UnixComponent, Utf8UnixPathBuf};
use crate::{utils, MonocoreError, MonocoreResult};
use super::monocore::{Monocore, Service};
impl Monocore {
pub fn validate(&self) -> MonocoreResult<()> {
let mut errors = Vec::new();
let service_names = self.validate_service_names(&mut errors);
let group_names = self.validate_group_names(&mut errors);
let volume_map = self.build_volume_group_map();
let env_map = self.build_env_group_map();
self.validate_services(
&service_names,
&group_names,
&volume_map,
&env_map,
&mut errors,
);
if let Err(cycle) = self.check_circular_dependencies() {
errors.push(format!("Circular dependency detected: {}", cycle));
}
if errors.is_empty() {
Ok(())
} else {
Err(MonocoreError::ConfigValidationErrors(errors))
}
}
fn validate_service_names(&self, errors: &mut Vec<String>) -> HashSet<&str> {
let mut service_names = HashSet::new();
for service in &self.services {
let service_name = service.get_name();
if !service_names.insert(service_name) {
errors.push(format!("Duplicate service name '{}'", service_name));
}
}
service_names
}
fn validate_group_names(&self, errors: &mut Vec<String>) -> HashSet<&str> {
let mut group_names = HashSet::new();
for group in &self.groups {
let group_name = group.get_name();
if !group_names.insert(group_name.as_str()) {
errors.push(format!("Duplicate group name '{}'", group_name));
}
}
group_names
}
fn build_volume_group_map(&self) -> HashMap<&str, &str> {
self.groups
.iter()
.flat_map(|g| {
g.get_volumes()
.iter()
.map(|v| (v.get_name().as_str(), g.get_name().as_str()))
})
.collect()
}
fn build_env_group_map(&self) -> HashMap<&str, &str> {
self.groups
.iter()
.flat_map(|g| {
g.get_envs()
.iter()
.map(|e| (e.get_name().as_str(), g.get_name().as_str()))
})
.collect()
}
fn validate_services(
&self,
service_names: &HashSet<&str>,
group_names: &HashSet<&str>,
volume_map: &HashMap<&str, &str>,
env_map: &HashMap<&str, &str>,
errors: &mut Vec<String>,
) {
self.validate_service_ports(&self.services, errors);
self.validate_service_volumes(&self.services, errors);
for service in &self.services {
self.validate_service_declarations(service, errors);
self.validate_service_group(service, group_names, errors);
self.validate_service_group_volumes(service, volume_map, errors);
self.validate_service_group_envs(service, env_map, errors);
self.validate_service_dependencies(service, service_names, errors);
}
}
fn validate_service_ports(&self, services: &[Service], errors: &mut Vec<String>) {
let mut used_ports: HashMap<Option<String>, HashMap<u16, String>> = HashMap::new();
for service in services {
if let Some(port) = &service.port {
let host_port = port.get_host();
let group_ports = used_ports.entry(service.group.clone()).or_default();
if let Some(existing_service) = group_ports.get(&host_port) {
errors.push(format!(
"Port {} is already in use by service '{}' in group '{}'",
host_port,
existing_service,
service.group.as_deref().unwrap_or("default")
));
} else {
group_ports.insert(host_port, service.name.clone());
}
}
}
}
fn validate_service_group(
&self,
service: &Service,
group_names: &HashSet<&str>,
errors: &mut Vec<String>,
) {
if let Some(group) = &service.group {
if !group_names.contains(group.as_str()) {
errors.push(format!(
"Service '{}' references non-existent group '{}'",
service.name, group
));
}
}
}
fn validate_service_group_volumes(
&self,
service: &Service,
volume_map: &HashMap<&str, &str>,
errors: &mut Vec<String>,
) {
let service_name = &service.name;
for volume in service.get_group_volumes() {
let volume_name = volume.get_name();
match volume_map.get(volume_name.as_str()) {
None => {
errors.push(format!(
"Service '{}' references non-existent volume '{}'",
service_name, volume_name
));
}
Some(volume_group) => {
if let Some(service_group) = service.get_group() {
if service_group != *volume_group {
errors.push(format!(
"Service '{}' in group '{}' references volume '{}' from different group '{}'",
service_name, service_group, volume_name, volume_group
));
}
}
}
}
}
}
fn validate_service_group_envs(
&self,
service: &Service,
env_map: &HashMap<&str, &str>,
errors: &mut Vec<String>,
) {
let service_name = service.get_name();
for env in service.get_group_envs() {
match env_map.get(env.as_str()) {
None => {
errors.push(format!(
"Service '{}' references non-existent env group '{}'",
service_name, env
));
}
Some(env_group) => {
if let Some(service_group) = service.get_group() {
if service_group != *env_group {
errors.push(format!(
"Service '{}' in group '{}' references env group '{}' from different group '{}'",
service_name, service_group, env, env_group
));
}
}
}
}
}
}
fn validate_service_dependencies(
&self,
service: &Service,
service_names: &HashSet<&str>,
errors: &mut Vec<String>,
) {
let service_name = service.get_name();
for dep in service.get_depends_on() {
if !service_names.contains(dep.as_str()) {
errors.push(format!(
"Service '{}' depends on non-existent service '{}'",
service_name, dep
));
}
}
}
pub fn check_circular_dependencies(&self) -> MonocoreResult<()> {
let dep_graph = self.build_dependency_graph();
for service in &self.services {
let service_name = service.get_name();
let mut path = vec![service_name];
let mut visited = HashSet::new();
visited.insert(service_name);
if let Some(cycle) =
Self::find_cycle_from_service(service_name, &dep_graph, &mut visited, &mut path, 0)
{
return Err(MonocoreError::ConfigValidation(format!(
"Circular dependency detected: {}",
cycle.join(" -> ")
)));
}
}
Ok(())
}
fn build_dependency_graph(&self) -> HashMap<&str, Vec<&str>> {
let mut graph = HashMap::new();
for service in &self.services {
graph.insert(
service.get_name(),
service.depends_on.iter().map(|s| s.as_str()).collect(),
);
}
graph
}
fn find_cycle_from_service<'a>(
current: &'a str,
graph: &'a HashMap<&'a str, Vec<&'a str>>,
visited: &mut HashSet<&'a str>,
path: &mut Vec<&'a str>,
depth: usize,
) -> Option<Vec<&'a str>> {
if depth >= Monocore::MAX_DEPENDENCY_DEPTH {
return Some(path.clone()); }
if let Some(deps) = graph.get(current) {
for &dep in deps {
if path.contains(&dep) {
let mut cycle = path.clone();
cycle.push(dep);
return Some(cycle);
}
if visited.contains(&dep) {
continue;
}
visited.insert(dep);
path.push(dep);
if let Some(cycle) =
Self::find_cycle_from_service(dep, graph, visited, path, depth + 1)
{
return Some(cycle);
}
path.pop();
}
}
None
}
fn validate_service_declarations(&self, service: &Service, errors: &mut Vec<String>) {
let service_name = service.get_name();
let mut env_names = HashSet::new();
for env in service.get_group_envs() {
if !env_names.insert(env) {
errors.push(format!(
"Service '{}' has duplicate group environment reference '{}'",
service_name, env
));
}
}
let mut own_env_names = HashSet::new();
for env in service.get_own_envs() {
let env_name = env.get_name();
if !own_env_names.insert(env_name) {
errors.push(format!(
"Service '{}' has duplicate own environment variable '{}'",
service_name, env_name
));
}
}
let mut volume_names = HashSet::new();
for volume in service.get_group_volumes() {
if !volume_names.insert(volume.get_name()) {
errors.push(format!(
"Service '{}' has duplicate group volume reference '{}'",
service_name,
volume.get_name()
));
}
}
let mut own_volume_paths = HashSet::new();
for volume in service.get_own_volumes() {
let host_path = volume.get_host().to_string();
if !own_volume_paths.insert(host_path.clone()) {
errors.push(format!(
"Service '{}' has duplicate own volume path '{}'",
service_name, host_path
));
}
}
let mut dep_names = HashSet::new();
for dep in service.get_depends_on() {
if !dep_names.insert(dep) {
errors.push(format!(
"Service '{}' has duplicate dependency '{}'",
service_name, dep
));
}
}
}
fn validate_service_volumes(&self, services: &[Service], errors: &mut Vec<String>) {
let mut volume_paths: Vec<(String, String, bool)> = Vec::new();
for group in &self.groups {
let group_name = group.get_name();
for volume in group.get_volumes() {
let normalized_path = match normalize_path(volume.get_path(), true) {
Ok(path) => path,
Err(e) => {
errors.push(format!(
"Invalid volume path '{}' in group '{}': {}",
volume.get_path(),
group_name,
e
));
continue;
}
};
volume_paths.push((normalized_path, group_name.to_string(), true));
}
}
for service in services {
let service_name = service.get_name();
for volume in service.get_own_volumes() {
let host_path = volume.get_host();
let normalized_path = match normalize_path(host_path.as_str(), true) {
Ok(path) => path,
Err(e) => {
errors.push(format!(
"Invalid volume path '{}' in service '{}': {}",
host_path, service_name, e
));
continue;
}
};
volume_paths.push((normalized_path, service_name.to_string(), false));
}
for volume_mount in service.get_group_volumes() {
let volume_name = volume_mount.get_name();
let group_name = match service.get_group() {
Some(group_name) => group_name.to_string(),
None => {
errors.push(format!(
"Service '{}' references group volume '{}' but has no group assigned",
service_name, volume_name
));
continue;
}
};
let group = match self.get_group(&group_name) {
Some(group) => group,
None => continue, };
let group_volume = match group
.get_volumes()
.iter()
.find(|v| v.get_name() == volume_name)
{
Some(volume) => volume,
None => continue, };
let base_path = group_volume.get_path();
let mount_path = volume_mount.get_mount().get_host().to_string();
match normalize_volume_path(base_path, &mount_path) {
Ok(normalized_path) => {
volume_paths.push((normalized_path, service_name.to_string(), false));
}
Err(e) => {
errors.push(format!(
"Invalid volume mount path '{}' for group volume '{}' in service '{}': {}",
mount_path, volume_name, service_name, e
));
}
}
}
}
for i in 0..volume_paths.len() {
let (path1, source1, is_group1) = &volume_paths[i];
for (path2, source2, is_group2) in volume_paths.iter().skip(i + 1) {
if source1 == source2 {
continue;
}
let group1 = if *is_group1 {
Some(source1.as_str())
} else {
self.get_service(source1).and_then(|s| s.get_group())
};
let group2 = if *is_group2 {
Some(source2.as_str())
} else {
self.get_service(source2).and_then(|s| s.get_group())
};
if let (Some(g1), Some(g2)) = (group1, group2) {
if g1 == g2 {
continue;
}
}
let is_conflict = utils::paths_overlap(path1, path2);
if is_conflict {
let source1_type = if *is_group1 { "group" } else { "service" };
let source2_type = if *is_group2 { "group" } else { "service" };
errors.push(format!(
"Volume path conflict detected: path '{}' from {} '{}' conflicts with path '{}' from {} '{}'. \
Volume paths cannot overlap between different groups or services",
path1, source1_type, source1,
path2, source2_type, source2
));
}
}
}
}
}
pub fn normalize_path(path: &str, require_absolute: bool) -> MonocoreResult<String> {
if path.is_empty() {
return Err(MonocoreError::PathValidation(
"Path cannot be empty".to_string(),
));
}
let path = Utf8UnixPathBuf::from(path);
let mut normalized = Vec::new();
let mut is_absolute = false;
let mut depth = 0;
for component in path.components() {
match component {
Utf8UnixComponent::RootDir => {
if normalized.is_empty() {
is_absolute = true;
normalized.push("/".to_string());
} else {
return Err(MonocoreError::PathValidation(
"Invalid path: root component '/' found in middle of path".to_string(),
));
}
}
Utf8UnixComponent::ParentDir => {
if depth > 0 {
normalized.pop();
depth -= 1;
} else {
return Err(MonocoreError::PathValidation(
"Invalid path: cannot traverse above root directory".to_string(),
));
}
}
Utf8UnixComponent::CurDir => continue,
Utf8UnixComponent::Normal(c) => {
if !c.is_empty() {
normalized.push(c.to_string());
depth += 1;
}
}
}
}
if require_absolute && !is_absolute {
return Err(MonocoreError::PathValidation(
"Host mount paths must be absolute (start with '/')".to_string(),
));
}
if is_absolute {
if normalized.len() == 1 {
Ok("/".to_string())
} else {
Ok(format!("/{}", normalized[1..].join("/")))
}
} else {
Ok(normalized.join("/"))
}
}
pub fn normalize_volume_path(base_path: &str, requested_path: &str) -> MonocoreResult<String> {
let normalized_base = normalize_path(base_path, true)?;
if requested_path.starts_with('/') {
let normalized_requested = normalize_path(requested_path, true)?;
if !normalized_requested.starts_with(&normalized_base) {
return Err(MonocoreError::PathValidation(format!(
"Absolute path '{}' must be under base path '{}'",
normalized_requested, normalized_base
)));
}
Ok(normalized_requested)
} else {
let normalized_requested = normalize_path(requested_path, false)?;
let full_path = format!("{}/{}", normalized_base, normalized_requested);
normalize_path(&full_path, true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
monocore::{Group, GroupEnv, GroupVolume},
EnvPair, PathPair, PortPair, VolumeMount,
};
mod fixtures {
use super::*;
pub fn create_test_service(name: &str) -> Service {
Service::builder().name(name).command("./test").build()
}
pub fn create_test_group(name: &str) -> Group {
Group::builder()
.name(name)
.volumes(vec![])
.envs(vec![])
.local_only(true)
.build()
}
}
#[test]
fn test_normalize_path() {
assert_eq!(normalize_path("/data/app/", true).unwrap(), "/data/app");
assert_eq!(normalize_path("/data//app", true).unwrap(), "/data/app");
assert_eq!(normalize_path("/data/./app", true).unwrap(), "/data/app");
assert_eq!(normalize_path("data/app/", false).unwrap(), "data/app");
assert_eq!(normalize_path("./data/app", false).unwrap(), "data/app");
assert_eq!(normalize_path("data//app", false).unwrap(), "data/app");
assert_eq!(
normalize_path("/data/temp/../app", true).unwrap(),
"/data/app"
);
assert_eq!(
normalize_path("data/temp/../app", false).unwrap(),
"data/app"
);
assert!(matches!(
normalize_path("data/app", true),
Err(MonocoreError::PathValidation(e)) if e.contains("must be absolute")
));
assert!(matches!(
normalize_path("/data/../..", true),
Err(MonocoreError::PathValidation(e)) if e.contains("cannot traverse above root")
));
assert!(matches!(
normalize_path("data/../..", false),
Err(MonocoreError::PathValidation(e)) if e.contains("cannot traverse above root")
));
}
#[test]
fn test_normalize_path_complex() {
assert_eq!(
normalize_path("/data/./temp/../logs/app/./config/../", true).unwrap(),
"/data/logs/app"
);
assert_eq!(
normalize_path("/data///temp/././../app//./test/..", true).unwrap(),
"/data/app"
);
assert_eq!(normalize_path("/data/./././.", true).unwrap(), "/data");
assert_eq!(
normalize_path("/data/test/../../data/app", true).unwrap(),
"/data/app"
);
assert!(matches!(
normalize_path("/data/test/../../../root", true),
Err(MonocoreError::PathValidation(e)) if e.contains("cannot traverse above root")
));
assert!(matches!(
normalize_path("/./data/../..", true),
Err(MonocoreError::PathValidation(e)) if e.contains("cannot traverse above root")
));
}
#[test]
fn test_monocore_validate_service_names_unique() {
let mut monocore = Monocore {
services: vec![
fixtures::create_test_service("service1"),
fixtures::create_test_service("service2"),
],
groups: vec![],
};
let mut errors = Vec::new();
let names = monocore.validate_service_names(&mut errors);
assert_eq!(names.len(), 2);
assert!(errors.is_empty());
monocore = Monocore {
services: vec![
fixtures::create_test_service("service1"),
fixtures::create_test_service("service1"),
],
groups: vec![],
};
let mut errors = Vec::new();
monocore.validate_service_names(&mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Duplicate service name"));
}
#[test]
fn test_monocore_validate_group_names_unique() {
let mut monocore = Monocore {
groups: vec![
fixtures::create_test_group("group1"),
fixtures::create_test_group("group2"),
],
services: vec![],
};
let mut errors = Vec::new();
let names = monocore.validate_group_names(&mut errors);
assert_eq!(names.len(), 2);
assert!(errors.is_empty());
monocore = Monocore {
groups: vec![
fixtures::create_test_group("group1"),
fixtures::create_test_group("group1"),
],
services: vec![],
};
let mut errors = Vec::new();
monocore.validate_group_names(&mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Duplicate group name"));
}
#[test]
fn test_monocore_validate_service_group() {
let service = Service::builder()
.name("test-service")
.group("test-group")
.command("./test")
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![fixtures::create_test_group("test-group")],
};
let mut errors = Vec::new();
let group_names = monocore.validate_group_names(&mut errors);
monocore.validate_service_group(&monocore.services[0], &group_names, &mut errors);
assert!(errors.is_empty());
let service = Service::builder()
.name("test-service")
.group("non-existent")
.command("./test")
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![fixtures::create_test_group("test-group")],
};
let mut errors = Vec::new();
let group_names = monocore.validate_group_names(&mut errors);
monocore.validate_service_group(&monocore.services[0], &group_names, &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("references non-existent group"));
}
#[test]
fn test_monocore_validate_service_group_volumes() {
let mut group = fixtures::create_test_group("test-group");
group.volumes = vec![GroupVolume {
name: "test-volume".to_string(),
path: "/test".to_string(),
}];
let service = Service::builder()
.name("test-service")
.group("test-group")
.command("./test")
.group_volumes(vec![VolumeMount::builder()
.name("test-volume")
.mount("/test:/test".parse().unwrap())
.build()])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![group],
};
let mut errors = Vec::new();
let volume_map = monocore.build_volume_group_map();
monocore.validate_service_group_volumes(&monocore.services[0], &volume_map, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn test_monocore_validate_service_group_envs() {
let mut group = fixtures::create_test_group("test-group");
group.envs = vec![GroupEnv {
name: "test-env".to_string(),
envs: vec![EnvPair::new("TEST", "value")],
}];
let service = Service::builder()
.name("test-service")
.group("test-group")
.command("./test")
.group_envs(vec!["test-env".to_string()])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![group],
};
let mut errors = Vec::new();
let env_map = monocore.build_env_group_map();
monocore.validate_service_group_envs(&monocore.services[0], &env_map, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn test_monocore_validate_service_dependencies() {
let service1 = Service::builder()
.name("service1")
.command("./test1")
.depends_on(vec!["service2".to_string()])
.build();
let service2 = Service::builder()
.name("service2")
.command("./test2")
.build();
let monocore = Monocore {
services: vec![service1, service2],
groups: vec![],
};
let mut errors = Vec::new();
let service_names = monocore.validate_service_names(&mut errors);
monocore.validate_service_dependencies(&monocore.services[0], &service_names, &mut errors);
assert!(errors.is_empty());
let service = Service::builder()
.name("test-service")
.command("./test")
.depends_on(vec!["non-existent".to_string()])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![],
};
let mut errors = Vec::new();
let service_names = monocore.validate_service_names(&mut errors);
monocore.validate_service_dependencies(&monocore.services[0], &service_names, &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("depends on non-existent service"));
}
#[test]
fn test_monocore_validate_service_declarations() {
let service = Service::builder()
.name("test-service")
.command("./test")
.group_envs(vec!["env1".to_string(), "env1".to_string()])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![],
};
let mut errors = Vec::new();
monocore.validate_service_declarations(&monocore.services[0], &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("duplicate group environment reference"));
let service = Service::builder()
.name("test-service")
.command("./test")
.envs(vec![
"TEST=value1".parse().unwrap(),
"TEST=value2".parse().unwrap(),
])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![],
};
let mut errors = Vec::new();
monocore.validate_service_declarations(&monocore.services[0], &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("duplicate own environment variable"));
let service = Service::builder()
.name("test-service")
.command("./test")
.group_volumes(vec![
VolumeMount::builder()
.name("vol1")
.mount("/test:/test".parse().unwrap())
.build(),
VolumeMount::builder()
.name("vol1")
.mount("/test2:/test2".parse().unwrap())
.build(),
])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![],
};
let mut errors = Vec::new();
monocore.validate_service_declarations(&monocore.services[0], &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("duplicate group volume reference"));
let service = Service::builder()
.name("test-service")
.command("./test")
.volumes(vec![
"/data:/container1".parse().unwrap(),
"/data:/container2".parse().unwrap(),
])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![],
};
let mut errors = Vec::new();
monocore.validate_service_declarations(&monocore.services[0], &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("duplicate own volume path"));
let service = Service::builder()
.name("test-service")
.command("./test")
.depends_on(vec!["dep1".to_string(), "dep1".to_string()])
.build();
let monocore = Monocore {
services: vec![service],
groups: vec![],
};
let mut errors = Vec::new();
monocore.validate_service_declarations(&monocore.services[0], &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("duplicate dependency"));
}
#[test]
fn test_validate_service_ports() {
let service1 = Service::builder()
.name("service1")
.group("test-group")
.port("8080:8080".parse::<PortPair>().unwrap())
.command("./test1")
.build();
let service2 = Service::builder()
.name("service2")
.group("test-group")
.port("8080:8080".parse::<PortPair>().unwrap())
.command("./test2")
.build();
let group = Group::builder().name("test-group").build();
let config = Monocore {
services: vec![service1, service2],
groups: vec![group],
};
let mut errors = Vec::new();
config.validate_service_ports(&config.services, &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Port 8080 is already in use"));
}
#[test]
fn test_validate_service_ports_different_groups() {
let service1 = Service::builder()
.name("service1")
.group("group1")
.port("8080:8080".parse::<PortPair>().unwrap())
.command("./test1")
.build();
let service2 = Service::builder()
.name("service2")
.group("group2")
.port("8080:8080".parse::<PortPair>().unwrap())
.command("./test2")
.build();
let group1 = Group::builder().name("group1").build();
let group2 = Group::builder().name("group2").build();
let config = Monocore {
services: vec![service1, service2],
groups: vec![group1, group2],
};
let mut errors = Vec::new();
config.validate_service_ports(&config.services, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn test_validate_service_ports_no_group() {
let service1 = Service::builder()
.name("service1")
.port("8080:8080".parse::<PortPair>().unwrap())
.command("./test1")
.build();
let service2 = Service::builder()
.name("service2")
.port("8080:8080".parse::<PortPair>().unwrap())
.command("./test2")
.build();
let config = Monocore {
services: vec![service1, service2],
groups: vec![],
};
let mut errors = Vec::new();
config.validate_service_ports(&config.services, &mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Port 8080 is already in use"));
}
#[test]
fn test_monocore_validate_check_circular_dependencies() {
let service1 = Service::builder()
.name("service1")
.command("./test1")
.depends_on(vec!["service2".to_string()])
.build();
let service2 = Service::builder()
.name("service2")
.command("./test2")
.depends_on(vec!["service3".to_string()])
.build();
let service3 = Service::builder()
.name("service3")
.command("./test3")
.depends_on(vec!["service1".to_string()])
.build();
let monocore = Monocore {
services: vec![service1, service2, service3],
groups: vec![],
};
let result = monocore.check_circular_dependencies();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Circular dependency detected"));
}
#[test]
fn test_validate_service_volumes_cross_group_conflict() {
let group1 = Group::builder().name("group1").build();
let group2 = Group::builder().name("group2").build();
let service1 = Service::builder()
.name("service1")
.group("group1")
.volumes(vec!["/data:/app".parse::<PathPair>().unwrap()])
.command("./test1")
.build();
let service2 = Service::builder()
.name("service2")
.group("group2")
.volumes(vec!["/data:/other".parse::<PathPair>().unwrap()])
.command("./test2")
.build();
let config = Monocore {
services: vec![service1, service2],
groups: vec![group1, group2],
};
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("conflicts with path"));
}
#[test]
fn test_validate_service_volumes_path_normalization() {
let group1 = Group::builder().name("group1").build();
let group2 = Group::builder().name("group2").build();
let service1 = Service::builder()
.name("service1")
.group("group1")
.volumes(vec!["/data/app/".parse::<PathPair>().unwrap()])
.command("./test1")
.build();
let service2 = Service::builder()
.name("service2")
.group("group2")
.volumes(vec!["/data//app".parse::<PathPair>().unwrap()])
.command("./test2")
.build();
let config = Monocore {
services: vec![service1, service2],
groups: vec![group1, group2],
};
let result = config.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("conflicts with path"));
}
#[test]
fn test_validate_service_volumes_overlapping_paths() {
let group1 = Group::builder().name("group1").build();
let group2 = Group::builder().name("group2").build();
let test_cases = vec![
("/data/app:/container", "/data/app:/other"),
("/data:/container", "/data/app:/other"),
("/data/app/logs:/container", "/data:/other"),
("/data/apps/service1/logs:/container", "/data/apps:/other"),
("/data/./app/logs:/container", "/data/app:/other"),
];
for (path1, path2) in test_cases {
let service1 = Service::builder()
.name("service1")
.group("group1")
.volumes(vec![path1.parse::<PathPair>().unwrap()])
.command("./test1")
.build();
let service2 = Service::builder()
.name("service2")
.group("group2")
.volumes(vec![path2.parse::<PathPair>().unwrap()])
.command("./test2")
.build();
let config = Monocore {
services: vec![service1, service2],
groups: vec![group1.clone(), group2.clone()],
};
let result = config.validate();
println!(">> result: {:?}", result);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("conflicts with path"));
}
let service1 = Service::builder()
.name("service1")
.group("group1")
.volumes(vec!["/data1/app:/container".parse::<PathPair>().unwrap()])
.command("./test1")
.build();
let service2 = Service::builder()
.name("service2")
.group("group2")
.volumes(vec!["/data2/app:/other".parse::<PathPair>().unwrap()])
.command("./test2")
.build();
let config = Monocore {
services: vec![service1, service2],
groups: vec![group1, group2],
};
let result = config.validate();
assert!(
result.is_ok(),
"Non-overlapping paths should validate successfully"
);
}
#[test]
fn test_validate_service_volume_paths() {
let group = Group::builder()
.name("test-group")
.volumes(vec![GroupVolume::builder()
.name("data")
.path("/data")
.build()])
.build();
let service1 = Service::builder()
.name("service1")
.volumes(vec!["data/app:/app".parse::<PathPair>().unwrap()])
.command("./test1")
.build();
let config1 = Monocore::builder()
.services(vec![service1])
.groups(vec![group.clone()])
.build_unchecked();
let result = config1.validate();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Host mount paths must be absolute"));
let service2 = Service::builder()
.name("service2")
.volumes(vec!["/var/lib/../../../etc:/etc"
.parse::<PathPair>()
.unwrap()])
.command("./test2")
.build();
let config2 = Monocore::builder()
.services(vec![service2])
.groups(vec![group.clone()])
.build_unchecked();
let result = config2.validate();
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("cannot traverse above root"));
let service3 = Service::builder()
.name("service3")
.volumes(vec!["/var/./lib//app:/app".parse::<PathPair>().unwrap()])
.command("./test3")
.build();
let config3 = Monocore::builder()
.services(vec![service3])
.groups(vec![group])
.build_unchecked();
let result = config3.validate();
assert!(result.is_ok());
}
}