diff --git a/docker-compose.yml b/docker-compose.yml index 630b0f4..f1bb173 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,56 @@ -# Generated by Athena v0.1.0 from MISSING_IMAGE_TEST deployment -# Generated: 2025-09-16 16:56:36 UTC +# Generated by Athena v0.1.0 from test_no_conflicts deployment +# Generated: 2025-09-17 13:08:45 UTC # Features: Intelligent defaults, optimized networking, enhanced health checks # DO NOT EDIT MANUALLY - This file is auto-generated -# Services: 1 configured with intelligent defaults +# Services: 3 configured with intelligent defaults services: - test_service: - build: - context: . - dockerfile: Dockerfile - container_name: missing-image-test-test_service + app2: + image: httpd:alpine + container_name: test-no-conflicts-app2 ports: - - 8080:80 + - 8081:8000 restart: unless-stopped networks: - - missing_image_test_network + - test_no_conflicts_network pull_policy: missing labels: - athena.project: MISSING_IMAGE_TEST + athena.project: test_no_conflicts athena.type: generic - athena.service: test_service - athena.generated: 2025-09-16 + athena.service: app2 + athena.generated: 2025-09-17 + + app1: + image: nginx:alpine + container_name: test-no-conflicts-app1 + ports: + - 8080:80 + restart: always + networks: + - test_no_conflicts_network + pull_policy: missing + labels: + athena.service: app1 + athena.type: proxy + athena.project: test_no_conflicts + athena.generated: 2025-09-17 + + app3: + image: apache:latest + container_name: test-no-conflicts-app3 + ports: + - 9000:80 + restart: always + networks: + - test_no_conflicts_network + pull_policy: missing + labels: + athena.type: proxy + athena.project: test_no_conflicts + athena.service: app3 + athena.generated: 2025-09-17 networks: - missing_image_test_network: + test_no_conflicts_network: driver: bridge -name: MISSING_IMAGE_TEST \ No newline at end of file +name: test_no_conflicts \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md index 2470a47..e6f8b2e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -126,12 +126,17 @@ cargo test --test integration_tests structural --verbose - Tests environment variable templating - Tests port mappings, volume mounts - Tests health checks and resource limits +- Tests port conflict detection during generation +- Tests successful generation with different ports - Validates YAML syntax and structure ### 3. Error Handling Tests (`error_handling_test.rs`) - Tests file not found scenarios - Tests invalid syntax handling - Tests circular dependency detection +- Tests port conflict detection with detailed error messages +- Tests port conflict suggestions for resolution +- Tests mixed port mapping scenarios - Tests malformed configuration errors - Tests permission and access errors - Validates error message quality @@ -252,10 +257,10 @@ Modular boilerplate tests organized by framework, each verifying: ### Test Performance & Statistics **Current test suite:** -- **Total tests**: 67 integration tests +- **Total tests**: 69 integration tests - **CLI tests**: 13 tests (command parsing, help, validation) -- **Docker Compose generation**: 9 tests (YAML generation and validation) -- **Error handling**: 18 tests (comprehensive error scenarios) +- **Docker Compose generation**: 11 tests (YAML generation, validation, port conflict detection) +- **Error handling**: 21 tests (comprehensive error scenarios including port conflicts) - **Boilerplate generation**: 14 tests (modular by framework) - **Structural tests**: 13 tests (organized in 6 categories) - **Execution time**: < 1 second for structural tests @@ -277,11 +282,57 @@ Modular boilerplate tests organized by framework, each verifying: - `go_tests.rs`: 3 tests (Gin, Echo, Fiber frameworks) - `common_tests.rs`: 3 tests (error handling, validation, help commands) +## Port Conflict Detection Tests + +### Overview +As of the latest update, Athena includes comprehensive port conflict detection that prevents Docker Compose generation when multiple services attempt to use the same host port. + +### Test Coverage + +#### In `docker_compose_generation_test.rs`: +- **`test_port_conflict_prevention_in_generation`**: Verifies that Docker Compose generation fails when multiple services use the same host port (e.g., two services both mapping to port 8080) +- **`test_successful_generation_with_different_ports`**: Confirms that generation succeeds when services use different host ports (e.g., one service uses 8080, another uses 8081) + +#### In `error_handling_test.rs`: +- **`test_port_conflict_detection`**: Tests basic port conflict detection using the fixture file +- **`test_port_conflict_suggestions`**: Verifies that the error message includes helpful port suggestions (e.g., "Consider using different ports like: 3000, 3001, 3002") +- **`test_no_port_conflicts_different_ports`**: Ensures no false positives when services use different ports +- **`test_port_conflict_with_mixed_mappings`**: Tests scenarios where services have multiple port mappings with conflicts + +### Key Features Tested +1. **Conflict Detection**: Identifies when multiple services use the same host port +2. **Detailed Error Messages**: Provides clear information about which services are conflicting +3. **Port Suggestions**: Automatically generates alternative port suggestions +4. **Mixed Mappings**: Handles services with multiple port mappings correctly +5. **Integration with Generation**: Prevents invalid Docker Compose file creation + +### Example Test Scenarios +```rust +// Conflict scenario - should fail +SERVICE service1 +PORT-MAPPING 8080 TO 80 +END SERVICE + +SERVICE service2 +PORT-MAPPING 8080 TO 8000 // ❌ Conflict on host port 8080 +END SERVICE + +// Valid scenario - should succeed +SERVICE service1 +PORT-MAPPING 8080 TO 80 +END SERVICE + +SERVICE service2 +PORT-MAPPING 8081 TO 8000 // ✅ Different host port +END SERVICE +``` + ### Coverage Goals The test suite aims for >80% coverage on critical code paths: - CLI argument parsing - .ath file parsing and validation - Docker Compose generation +- Port conflict detection and validation - Error handling and reporting - Project initialization diff --git a/src/athena/generator/compose.rs b/src/athena/generator/compose.rs index bd28779..6cac10c 100644 --- a/src/athena/generator/compose.rs +++ b/src/athena/generator/compose.rs @@ -170,6 +170,9 @@ fn validate_compose_enhanced(compose: &DockerCompose) -> AthenaResult<()> { // Fast circular dependency detection detect_circular_dependencies_optimized(compose)?; + // Detect port conflicts between services + detect_port_conflicts(compose)?; + Ok(()) } @@ -266,6 +269,67 @@ fn has_cycle_iterative( Ok(false) } +/// Detect port conflicts between services +fn detect_port_conflicts(compose: &DockerCompose) -> AthenaResult<()> { + use std::collections::HashMap; + + let mut port_to_services: HashMap> = HashMap::new(); + + // Collect all host ports from all services + for (service_name, service) in &compose.services { + if let Some(ports) = &service.ports { + for port_mapping in ports { + if let Some(host_port) = extract_host_port(port_mapping) { + port_to_services + .entry(host_port) + .or_insert_with(Vec::new) + .push(service_name.clone()); + } + } + } + } + + // Check for conflicts + for (port, services) in port_to_services { + if services.len() > 1 { + return Err(AthenaError::ValidationError( + format!( + "Port conflict detected! Host port {} is used by multiple services: {}. \ + Each service must use a unique host port. Consider using different ports like: {}", + port, + services.join(", "), + generate_port_suggestions(&port, services.len()) + ) + )); + } + } + + Ok(()) +} + +/// Extract host port from port mapping (e.g., "8080:80" -> "8080") +fn extract_host_port(port_mapping: &str) -> Option { + let parts: Vec<&str> = port_mapping.split(':').collect(); + if parts.len() >= 2 { + Some(parts[0].to_string()) + } else { + None + } +} + +/// Generate port suggestions for conflicts +fn generate_port_suggestions(base_port: &str, count: usize) -> String { + if let Ok(port_num) = base_port.parse::() { + let mut suggestions = Vec::new(); + for i in 0..count { + suggestions.push((port_num + i as u16).to_string()); + } + suggestions.join(", ") + } else { + "8080, 8081, 8082".to_string() // fallback suggestions + } +} + /// Improve YAML formatting for better readability by adding blank lines between services fn improve_yaml_formatting(yaml: String) -> String { let lines: Vec<&str> = yaml.lines().collect(); @@ -369,4 +433,20 @@ mod tests { assert!(yaml.contains("restart: unless-stopped")); assert!(yaml.contains("container_name: test-project-backend")); } + + + #[test] + fn test_extract_host_port() { + assert_eq!(extract_host_port("8080:80"), Some("8080".to_string())); + assert_eq!(extract_host_port("3000:3000/tcp"), Some("3000".to_string())); + assert_eq!(extract_host_port("80"), None); + assert_eq!(extract_host_port(""), None); + } + + #[test] + fn test_port_suggestions() { + assert_eq!(generate_port_suggestions("8080", 3), "8080, 8081, 8082"); + assert_eq!(generate_port_suggestions("3000", 2), "3000, 3001"); + assert_eq!(generate_port_suggestions("invalid", 2), "8080, 8081, 8082"); + } } \ No newline at end of file diff --git a/tests/fixtures/port_conflicts.ath b/tests/fixtures/port_conflicts.ath new file mode 100644 index 0000000..4206522 --- /dev/null +++ b/tests/fixtures/port_conflicts.ath @@ -0,0 +1,13 @@ +DEPLOYMENT-ID test_project + +SERVICES SECTION + +SERVICE app1 +IMAGE-ID nginx:alpine +PORT-MAPPING 8080 TO 80 +END SERVICE + +SERVICE app2 +IMAGE-ID httpd:alpine +PORT-MAPPING 8080 TO 8000 +END SERVICE \ No newline at end of file diff --git a/tests/integration/docker_compose_generation_test.rs b/tests/integration/docker_compose_generation_test.rs index b0ebded..cf13100 100644 --- a/tests/integration/docker_compose_generation_test.rs +++ b/tests/integration/docker_compose_generation_test.rs @@ -421,4 +421,80 @@ END SERVICE"#; .any(|n| n.as_str() == Some("custom_network_name")); assert!(has_custom_network, "Service should be connected to custom network"); } +} + +#[test] +fn test_port_conflict_prevention_in_generation() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let ath_content = r#"DEPLOYMENT-ID PORT_CONFLICT_TEST + +SERVICES SECTION + +SERVICE service1 +IMAGE-ID nginx:alpine +PORT-MAPPING 8080 TO 80 +END SERVICE + +SERVICE service2 +IMAGE-ID apache:latest +PORT-MAPPING 8080 TO 8000 +END SERVICE"#; + + let ath_file = create_test_ath_file(&temp_dir, "port_conflict.ath", ath_content); + let output_file = temp_dir.path().join("docker-compose.yml").to_string_lossy().to_string(); + + // This should fail due to port conflict + let result = run_athena_build(&ath_file, &output_file); + assert!(result.is_err(), "Build should fail due to port conflict"); + + let error_message = result.unwrap_err().to_string(); + assert!(error_message.contains("Port conflict detected"), + "Error should mention port conflict detection"); + assert!(error_message.contains("8080"), + "Error should mention the conflicting port"); +} + +#[test] +fn test_successful_generation_with_different_ports() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let ath_content = r#"DEPLOYMENT-ID NO_PORT_CONFLICT_TEST + +SERVICES SECTION + +SERVICE service1 +IMAGE-ID nginx:alpine +PORT-MAPPING 8080 TO 80 +END SERVICE + +SERVICE service2 +IMAGE-ID apache:latest +PORT-MAPPING 8081 TO 8000 +END SERVICE"#; + + let ath_file = create_test_ath_file(&temp_dir, "no_conflict.ath", ath_content); + let output_file = temp_dir.path().join("docker-compose.yml").to_string_lossy().to_string(); + + // This should succeed with different ports + let yaml_content = run_athena_build(&ath_file, &output_file) + .expect("Build should succeed with different ports"); + + let parsed: Value = parse_yaml_safely(&yaml_content) + .expect("Generated YAML should be valid"); + + let services = parsed["services"].as_mapping().expect("Services should be a mapping"); + assert!(services.contains_key("service1"), "Should contain service1"); + assert!(services.contains_key("service2"), "Should contain service2"); + + // Verify both services have their respective ports + let service1 = &services["service1"]; + let service2 = &services["service2"]; + + let service1_ports = service1["ports"].as_sequence().expect("Service1 should have ports"); + let service2_ports = service2["ports"].as_sequence().expect("Service2 should have ports"); + + let port1_str = service1_ports[0].as_str().unwrap(); + let port2_str = service2_ports[0].as_str().unwrap(); + + assert!(port1_str.contains("8080"), "Service1 should use port 8080"); + assert!(port2_str.contains("8081"), "Service2 should use port 8081"); } \ No newline at end of file diff --git a/tests/integration/error_handling_test.rs b/tests/integration/error_handling_test.rs index 6f39ba5..597bbe6 100644 --- a/tests/integration/error_handling_test.rs +++ b/tests/integration/error_handling_test.rs @@ -440,4 +440,124 @@ fn test_verbose_error_output() { cmd.assert() .failure() .stderr(predicate::str::contains("Error:")); +} + +// Port conflict detection tests +#[test] +fn test_port_conflict_detection() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let ath_file = create_test_ath_file( + &temp_dir, + "port_conflicts.ath", + include_str!("../fixtures/port_conflicts.ath"), + ); + + let mut cmd = Command::cargo_bin("athena").expect("Failed to find athena binary"); + cmd.arg("build").arg(&ath_file); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("Port conflict detected")) + .stderr(predicate::str::contains("8080")) + .stderr(predicate::str::contains("app1")) + .stderr(predicate::str::contains("app2")) + .stderr(predicate::str::contains("Consider using different ports")); +} + +#[test] +fn test_port_conflict_suggestions() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let port_conflict_content = r#"DEPLOYMENT-ID test_suggestions + +SERVICES SECTION + +SERVICE service1 +IMAGE-ID nginx:alpine +PORT-MAPPING 3000 TO 80 +END SERVICE + +SERVICE service2 +IMAGE-ID apache:latest +PORT-MAPPING 3000 TO 8080 +END SERVICE + +SERVICE service3 +IMAGE-ID httpd:alpine +PORT-MAPPING 3000 TO 8000 +END SERVICE"#; + + let ath_file = create_test_ath_file(&temp_dir, "port_suggestions.ath", port_conflict_content); + + let mut cmd = Command::cargo_bin("athena").expect("Failed to find athena binary"); + cmd.arg("build").arg(&ath_file); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("Port conflict detected")) + .stderr(predicate::str::contains("3000")) + .stderr(predicate::str::contains("Consider using different ports")) + .stderr(predicate::str::contains("3000, 3001, 3002")); +} + +#[test] +fn test_no_port_conflicts_different_ports() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let no_conflict_content = r#"DEPLOYMENT-ID test_no_conflicts + +SERVICES SECTION + +SERVICE app1 +IMAGE-ID nginx:alpine +PORT-MAPPING 8080 TO 80 +END SERVICE + +SERVICE app2 +IMAGE-ID httpd:alpine +PORT-MAPPING 8081 TO 8000 +END SERVICE + +SERVICE app3 +IMAGE-ID apache:latest +PORT-MAPPING 9000 TO 80 +END SERVICE"#; + + let ath_file = create_test_ath_file(&temp_dir, "no_conflicts.ath", no_conflict_content); + + let mut cmd = Command::cargo_bin("athena").expect("Failed to find athena binary"); + cmd.arg("build").arg(&ath_file); + + // This should succeed without port conflicts + cmd.assert() + .success(); +} + +#[test] +fn test_port_conflict_with_mixed_mappings() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let mixed_conflict_content = r#"DEPLOYMENT-ID test_mixed + +SERVICES SECTION + +SERVICE web +IMAGE-ID nginx:alpine +PORT-MAPPING 80 TO 80 +PORT-MAPPING 443 TO 443 +END SERVICE + +SERVICE api +IMAGE-ID node:alpine +PORT-MAPPING 80 TO 3000 +END SERVICE"#; + + let ath_file = create_test_ath_file(&temp_dir, "mixed_conflicts.ath", mixed_conflict_content); + + let mut cmd = Command::cargo_bin("athena").expect("Failed to find athena binary"); + cmd.arg("build").arg(&ath_file); + + cmd.assert() + .failure() + .stderr(predicate::str::contains("Port conflict detected")) + .stderr(predicate::str::contains("80")) + .stderr(predicate::str::contains("web")) + .stderr(predicate::str::contains("api")); } \ No newline at end of file