1use crate::error::Result;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct DockerAnalysis {
20 pub dockerfiles: Vec<DockerfileInfo>,
22 pub compose_files: Vec<ComposeFileInfo>,
24 pub services: Vec<DockerService>,
26 pub networking: NetworkingConfig,
28 pub orchestration_pattern: OrchestrationPattern,
30 pub environments: Vec<DockerEnvironment>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct DockerfileInfo {
37 pub path: PathBuf,
39 pub environment: Option<String>,
41 pub base_image: Option<String>,
43 pub exposed_ports: Vec<u16>,
45 pub workdir: Option<String>,
47 pub entrypoint: Option<String>,
49 pub env_vars: Vec<String>,
51 pub build_stages: Vec<String>,
53 pub is_multistage: bool,
55 pub instruction_count: usize,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct DiscoveredDockerfile {
65 pub path: PathBuf,
67 pub build_context: String,
69 pub suggested_service_name: String,
71 pub suggested_port: Option<u16>,
73 pub base_image: Option<String>,
75 pub is_multistage: bool,
77 pub environment: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct ComposeFileInfo {
84 pub path: PathBuf,
86 pub environment: Option<String>,
88 pub version: Option<String>,
90 pub service_names: Vec<String>,
92 pub networks: Vec<String>,
94 pub volumes: Vec<String>,
96 pub external_dependencies: Vec<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
102pub enum OrchestrationPattern {
103 SingleContainer,
105 DockerCompose,
107 Microservices,
109 EventDriven,
111 ServiceMesh,
113 Mixed,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119pub struct DockerService {
120 pub name: String,
122 pub compose_file: PathBuf,
124 pub image_or_build: ImageOrBuild,
126 pub ports: Vec<PortMapping>,
128 pub environment: HashMap<String, String>,
130 pub depends_on: Vec<String>,
132 pub networks: Vec<String>,
134 pub volumes: Vec<VolumeMount>,
136 pub health_check: Option<HealthCheck>,
138 pub restart_policy: Option<String>,
140 pub resource_limits: Option<ResourceLimits>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub enum ImageOrBuild {
147 Image(String),
149 Build {
151 context: String,
152 dockerfile: Option<String>,
153 args: HashMap<String, String>,
154 },
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub struct PortMapping {
160 pub host_port: Option<u16>,
162 pub container_port: u16,
164 pub protocol: String,
166 pub exposed_to_host: bool,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct VolumeMount {
173 pub source: String,
175 pub target: String,
177 pub mount_type: String,
179 pub read_only: bool,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185pub struct HealthCheck {
186 pub test: String,
188 pub interval: Option<String>,
190 pub timeout: Option<String>,
192 pub retries: Option<u32>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198pub struct ResourceLimits {
199 pub cpu_limit: Option<String>,
201 pub memory_limit: Option<String>,
203 pub cpu_reservation: Option<String>,
205 pub memory_reservation: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211pub struct NetworkingConfig {
212 pub custom_networks: Vec<NetworkInfo>,
214 pub service_discovery: ServiceDiscoveryConfig,
216 pub load_balancing: Vec<LoadBalancerConfig>,
218 pub external_connectivity: ExternalConnectivity,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
224pub struct NetworkInfo {
225 pub name: String,
227 pub driver: Option<String>,
229 pub external: bool,
231 pub connected_services: Vec<String>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
237pub struct ServiceDiscoveryConfig {
238 pub internal_dns: bool,
240 pub external_tools: Vec<String>,
242 pub service_mesh: bool,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct LoadBalancerConfig {
249 pub service: String,
251 pub lb_type: String,
253 pub backends: Vec<String>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259pub struct ExternalConnectivity {
260 pub exposed_services: Vec<ExposedService>,
262 pub ingress_patterns: Vec<String>,
264 pub api_gateways: Vec<String>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270pub struct ExposedService {
271 pub service: String,
273 pub external_ports: Vec<u16>,
275 pub protocols: Vec<String>,
277 pub ssl_enabled: bool,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283pub struct DockerEnvironment {
284 pub name: String,
286 pub dockerfiles: Vec<PathBuf>,
288 pub compose_files: Vec<PathBuf>,
290 pub config_overrides: HashMap<String, String>,
292}
293
294pub fn analyze_docker_infrastructure(project_root: &Path) -> Result<DockerAnalysis> {
296 log::info!(
297 "Starting Docker infrastructure analysis for: {}",
298 project_root.display()
299 );
300
301 let dockerfiles = find_dockerfiles(project_root)?;
303 let compose_files = find_compose_files(project_root)?;
304
305 log::debug!(
306 "Found {} Dockerfiles and {} Compose files",
307 dockerfiles.len(),
308 compose_files.len()
309 );
310
311 let parsed_dockerfiles: Vec<DockerfileInfo> = dockerfiles
313 .into_iter()
314 .filter_map(|path| parse_dockerfile(&path).ok())
315 .collect();
316
317 let parsed_compose_files: Vec<ComposeFileInfo> = compose_files
319 .into_iter()
320 .filter_map(|path| parse_compose_file(&path).ok())
321 .collect();
322
323 let services = extract_services_from_compose(&parsed_compose_files)?;
325
326 let networking = analyze_networking(&services, &parsed_compose_files)?;
328
329 let orchestration_pattern = determine_orchestration_pattern(&services, &networking);
331
332 let environments = analyze_environments(&parsed_dockerfiles, &parsed_compose_files);
334
335 Ok(DockerAnalysis {
336 dockerfiles: parsed_dockerfiles,
337 compose_files: parsed_compose_files,
338 services,
339 networking,
340 orchestration_pattern,
341 environments,
342 })
343}
344
345fn find_dockerfiles(project_root: &Path) -> Result<Vec<PathBuf>> {
347 let mut dockerfiles = Vec::new();
348
349 fn collect_dockerfiles_recursive(dir: &Path, dockerfiles: &mut Vec<PathBuf>) -> Result<()> {
350 if dir.file_name().is_some_and(|name| {
351 name == "node_modules" || name == ".git" || name == "target" || name == ".next"
352 }) {
353 return Ok(());
354 }
355
356 for entry in fs::read_dir(dir)? {
357 let entry = entry?;
358 let path = entry.path();
359
360 if path.is_dir() {
361 collect_dockerfiles_recursive(&path, dockerfiles)?;
362 } else if let Some(filename) = path.file_name().and_then(|n| n.to_str())
363 && is_dockerfile_name(filename)
364 {
365 dockerfiles.push(path);
366 }
367 }
368 Ok(())
369 }
370
371 collect_dockerfiles_recursive(project_root, &mut dockerfiles)?;
372
373 Ok(dockerfiles)
374}
375
376fn is_dockerfile_name(filename: &str) -> bool {
378 let filename_lower = filename.to_lowercase();
379
380 if filename_lower == "dockerfile" {
382 return true;
383 }
384
385 if filename_lower.starts_with("dockerfile.") {
387 return true;
388 }
389
390 if filename_lower.ends_with(".dockerfile") {
391 return true;
392 }
393
394 false
395}
396
397fn find_compose_files(project_root: &Path) -> Result<Vec<PathBuf>> {
399 let mut compose_files = Vec::new();
400
401 fn collect_compose_files_recursive(dir: &Path, compose_files: &mut Vec<PathBuf>) -> Result<()> {
402 if dir.file_name().is_some_and(|name| {
403 name == "node_modules" || name == ".git" || name == "target" || name == ".next"
404 }) {
405 return Ok(());
406 }
407
408 for entry in fs::read_dir(dir)? {
409 let entry = entry?;
410 let path = entry.path();
411
412 if path.is_dir() {
413 collect_compose_files_recursive(&path, compose_files)?;
414 } else if let Some(filename) = path.file_name().and_then(|n| n.to_str())
415 && is_compose_file_name(filename)
416 {
417 compose_files.push(path);
418 }
419 }
420 Ok(())
421 }
422
423 collect_compose_files_recursive(project_root, &mut compose_files)?;
424
425 Ok(compose_files)
426}
427
428fn is_compose_file_name(filename: &str) -> bool {
430 let filename_lower = filename.to_lowercase();
431
432 let patterns = [
434 "docker-compose.yml",
435 "docker-compose.yaml",
436 "compose.yml",
437 "compose.yaml",
438 ];
439
440 for pattern in &patterns {
442 if filename_lower == *pattern {
443 return true;
444 }
445 }
446
447 if filename_lower.starts_with("docker-compose.")
449 && (filename_lower.ends_with(".yml") || filename_lower.ends_with(".yaml"))
450 {
451 return true;
452 }
453
454 if filename_lower.starts_with("compose.")
455 && (filename_lower.ends_with(".yml") || filename_lower.ends_with(".yaml"))
456 {
457 return true;
458 }
459
460 false
461}
462
463fn parse_dockerfile(path: &PathBuf) -> Result<DockerfileInfo> {
465 let content = fs::read_to_string(path)?;
466 let lines: Vec<&str> = content.lines().collect();
467
468 let mut info = DockerfileInfo {
469 path: path.clone(),
470 environment: extract_environment_from_filename(path),
471 base_image: None,
472 exposed_ports: Vec::new(),
473 workdir: None,
474 entrypoint: None,
475 env_vars: Vec::new(),
476 build_stages: Vec::new(),
477 is_multistage: false,
478 instruction_count: 0,
479 };
480
481 let from_regex = Regex::new(r"(?i)^FROM\s+(.+?)(?:\s+AS\s+(.+))?$").unwrap();
483 let expose_regex = Regex::new(r"(?i)^EXPOSE\s+(.+)$").unwrap();
484 let workdir_regex = Regex::new(r"(?i)^WORKDIR\s+(.+)$").unwrap();
485 let cmd_regex = Regex::new(r"(?i)^CMD\s+(.+)$").unwrap();
486 let entrypoint_regex = Regex::new(r"(?i)^ENTRYPOINT\s+(.+)$").unwrap();
487 let env_regex = Regex::new(r"(?i)^ENV\s+(.+)$").unwrap();
488
489 for line in lines {
490 let line = line.trim();
491 if line.is_empty() || line.starts_with('#') {
492 continue;
493 }
494
495 info.instruction_count += 1;
496
497 if let Some(captures) = from_regex.captures(line) {
499 if info.base_image.is_none() {
500 info.base_image = Some(captures.get(1).unwrap().as_str().trim().to_string());
501 }
502 if let Some(stage_name) = captures.get(2) {
503 info.build_stages
504 .push(stage_name.as_str().trim().to_string());
505 info.is_multistage = true;
506 }
507 }
508
509 if let Some(captures) = expose_regex.captures(line) {
511 let ports_str = captures.get(1).unwrap().as_str();
512 for port in ports_str.split_whitespace() {
513 if let Ok(port_num) = port.parse::<u16>() {
514 info.exposed_ports.push(port_num);
515 }
516 }
517 }
518
519 if let Some(captures) = workdir_regex.captures(line) {
521 info.workdir = Some(captures.get(1).unwrap().as_str().trim().to_string());
522 }
523
524 if let Some(captures) = cmd_regex.captures(line)
526 && info.entrypoint.is_none()
527 {
528 info.entrypoint = Some(captures.get(1).unwrap().as_str().trim().to_string());
529 }
530
531 if let Some(captures) = entrypoint_regex.captures(line) {
532 info.entrypoint = Some(captures.get(1).unwrap().as_str().trim().to_string());
533 }
534
535 if let Some(captures) = env_regex.captures(line) {
537 info.env_vars
538 .push(captures.get(1).unwrap().as_str().trim().to_string());
539 }
540 }
541
542 Ok(info)
543}
544
545fn parse_compose_file(path: &PathBuf) -> Result<ComposeFileInfo> {
547 let content = fs::read_to_string(path)?;
548
549 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| {
551 crate::error::AnalysisError::DependencyParsing {
552 file: path.display().to_string(),
553 reason: format!("YAML parsing error: {}", e),
554 }
555 })?;
556
557 let mut info = ComposeFileInfo {
558 path: path.clone(),
559 environment: extract_environment_from_filename(path),
560 version: None,
561 service_names: Vec::new(),
562 networks: Vec::new(),
563 volumes: Vec::new(),
564 external_dependencies: Vec::new(),
565 };
566
567 if let Some(version) = yaml_value.get("version").and_then(|v| v.as_str()) {
569 info.version = Some(version.to_string());
570 }
571
572 if let Some(services) = yaml_value.get("services").and_then(|s| s.as_mapping()) {
574 for (service_name, _) in services {
575 if let Some(name) = service_name.as_str() {
576 info.service_names.push(name.to_string());
577 }
578 }
579 }
580
581 if let Some(networks) = yaml_value.get("networks").and_then(|n| n.as_mapping()) {
583 for (network_name, network_config) in networks {
584 if let Some(name) = network_name.as_str() {
585 info.networks.push(name.to_string());
586
587 if let Some(config) = network_config.as_mapping()
589 && config
590 .get("external")
591 .and_then(|e| e.as_bool())
592 .unwrap_or(false)
593 {
594 info.external_dependencies.push(format!("network:{}", name));
595 }
596 }
597 }
598 }
599
600 if let Some(volumes) = yaml_value.get("volumes").and_then(|v| v.as_mapping()) {
602 for (volume_name, volume_config) in volumes {
603 if let Some(name) = volume_name.as_str() {
604 info.volumes.push(name.to_string());
605
606 if let Some(config) = volume_config.as_mapping()
608 && config
609 .get("external")
610 .and_then(|e| e.as_bool())
611 .unwrap_or(false)
612 {
613 info.external_dependencies.push(format!("volume:{}", name));
614 }
615 }
616 }
617 }
618
619 Ok(info)
620}
621
622fn extract_environment_from_filename(path: &Path) -> Option<String> {
624 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
625 let filename_lower = filename.to_lowercase();
626
627 let map_env = |env: &str| -> Option<String> {
629 match env {
630 "dev" | "development" | "local" => Some("development".to_string()),
631 "prod" | "production" => Some("production".to_string()),
632 "test" | "testing" => Some("test".to_string()),
633 "stage" | "staging" => Some("staging".to_string()),
634 _ if env.len() <= 10 && !env.is_empty() => Some(env.to_string()),
635 _ => None,
636 }
637 };
638
639 if let Some(last_dot) = filename_lower.rfind('.')
641 && let Some(env_dot_pos) = filename_lower[..last_dot].rfind('.')
642 {
643 let env = &filename_lower[env_dot_pos + 1..last_dot];
644 if let Some(result) = map_env(env) {
645 return Some(result);
646 }
647 }
648
649 if let Some(dot_pos) = filename_lower.rfind('.') {
651 let ext = &filename_lower[dot_pos + 1..];
652 let base = &filename_lower[..dot_pos];
654 if (base.contains("dockerfile") || base.contains("docker-compose") || base == "compose")
655 && let Some(result) = map_env(ext)
656 {
657 return Some(result);
658 }
659 }
660 }
661 None
662}
663
664fn extract_services_from_compose(compose_files: &[ComposeFileInfo]) -> Result<Vec<DockerService>> {
666 let mut services = Vec::new();
667
668 for compose_file in compose_files {
669 let content = fs::read_to_string(&compose_file.path)?;
670 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| {
671 crate::error::AnalysisError::DependencyParsing {
672 file: compose_file.path.display().to_string(),
673 reason: format!("YAML parsing error: {}", e),
674 }
675 })?;
676
677 if let Some(services_yaml) = yaml_value.get("services").and_then(|s| s.as_mapping()) {
678 for (service_name, service_config) in services_yaml {
679 if let (Some(name), Some(config)) =
680 (service_name.as_str(), service_config.as_mapping())
681 {
682 let service = parse_docker_service(name, config, &compose_file.path)?;
683 services.push(service);
684 }
685 }
686 }
687 }
688
689 Ok(services)
690}
691
692fn parse_docker_service(
694 name: &str,
695 config: &serde_yaml::Mapping,
696 compose_file: &Path,
697) -> Result<DockerService> {
698 let mut service = DockerService {
699 name: name.to_string(),
700 compose_file: compose_file.to_path_buf(),
701 image_or_build: ImageOrBuild::Image("unknown".to_string()),
702 ports: Vec::new(),
703 environment: HashMap::new(),
704 depends_on: Vec::new(),
705 networks: Vec::new(),
706 volumes: Vec::new(),
707 health_check: None,
708 restart_policy: None,
709 resource_limits: None,
710 };
711
712 if let Some(image) = config.get("image").and_then(|i| i.as_str()) {
714 service.image_or_build = ImageOrBuild::Image(image.to_string());
715 } else if let Some(build_config) = config.get("build") {
716 if let Some(context) = build_config.as_str() {
717 service.image_or_build = ImageOrBuild::Build {
718 context: context.to_string(),
719 dockerfile: None,
720 args: HashMap::new(),
721 };
722 } else if let Some(build_mapping) = build_config.as_mapping() {
723 let context = build_mapping
724 .get("context")
725 .and_then(|c| c.as_str())
726 .unwrap_or(".")
727 .to_string();
728
729 let dockerfile = build_mapping
730 .get("dockerfile")
731 .and_then(|d| d.as_str())
732 .map(|s| s.to_string());
733
734 let mut args = HashMap::new();
735 if let Some(args_config) = build_mapping.get("args").and_then(|a| a.as_mapping()) {
736 for (key, value) in args_config {
737 if let (Some(k), Some(v)) = (key.as_str(), value.as_str()) {
738 args.insert(k.to_string(), v.to_string());
739 }
740 }
741 }
742
743 service.image_or_build = ImageOrBuild::Build {
744 context,
745 dockerfile,
746 args,
747 };
748 }
749 }
750
751 if let Some(ports_config) = config.get("ports").and_then(|p| p.as_sequence()) {
753 for port_item in ports_config {
754 if let Some(port_mapping) = parse_port_mapping(port_item) {
755 service.ports.push(port_mapping);
756 }
757 }
758 }
759
760 if let Some(env_config) = config.get("environment") {
762 parse_environment_variables(env_config, &mut service.environment);
763 }
764
765 if let Some(depends_config) = config.get("depends_on") {
767 if let Some(depends_sequence) = depends_config.as_sequence() {
768 for dep in depends_sequence {
769 if let Some(dep_name) = dep.as_str() {
770 service.depends_on.push(dep_name.to_string());
771 }
772 }
773 } else if let Some(depends_mapping) = depends_config.as_mapping() {
774 for (dep_name, _) in depends_mapping {
775 if let Some(name) = dep_name.as_str() {
776 service.depends_on.push(name.to_string());
777 }
778 }
779 }
780 }
781
782 if let Some(networks_config) = config.get("networks") {
784 if let Some(networks_sequence) = networks_config.as_sequence() {
785 for network in networks_sequence {
786 if let Some(network_name) = network.as_str() {
787 service.networks.push(network_name.to_string());
788 }
789 }
790 } else if let Some(networks_mapping) = networks_config.as_mapping() {
791 for (network_name, _) in networks_mapping {
792 if let Some(name) = network_name.as_str() {
793 service.networks.push(name.to_string());
794 }
795 }
796 }
797 }
798
799 if let Some(volumes_config) = config.get("volumes").and_then(|v| v.as_sequence()) {
801 for volume_item in volumes_config {
802 if let Some(volume_mount) = parse_volume_mount(volume_item) {
803 service.volumes.push(volume_mount);
804 }
805 }
806 }
807
808 if let Some(restart) = config.get("restart").and_then(|r| r.as_str()) {
810 service.restart_policy = Some(restart.to_string());
811 }
812
813 if let Some(healthcheck_config) = config.get("healthcheck").and_then(|h| h.as_mapping())
815 && let Some(test) = healthcheck_config.get("test").and_then(|t| t.as_str())
816 {
817 service.health_check = Some(HealthCheck {
818 test: test.to_string(),
819 interval: healthcheck_config
820 .get("interval")
821 .and_then(|i| i.as_str())
822 .map(|s| s.to_string()),
823 timeout: healthcheck_config
824 .get("timeout")
825 .and_then(|t| t.as_str())
826 .map(|s| s.to_string()),
827 retries: healthcheck_config
828 .get("retries")
829 .and_then(|r| r.as_u64())
830 .map(|r| r as u32),
831 });
832 }
833
834 Ok(service)
835}
836
837fn parse_port_mapping(port_value: &serde_yaml::Value) -> Option<PortMapping> {
839 if let Some(port_str) = port_value.as_str() {
840 if let Some(colon_pos) = port_str.find(':') {
842 let host_part = &port_str[..colon_pos];
843 let container_part = &port_str[colon_pos + 1..];
844
845 if let (Ok(host_port), Ok(container_port)) =
846 (host_part.parse::<u16>(), container_part.parse::<u16>())
847 {
848 return Some(PortMapping {
849 host_port: Some(host_port),
850 container_port,
851 protocol: "tcp".to_string(),
852 exposed_to_host: true,
853 });
854 }
855 } else if let Ok(container_port) = port_str.parse::<u16>() {
856 return Some(PortMapping {
857 host_port: None,
858 container_port,
859 protocol: "tcp".to_string(),
860 exposed_to_host: false,
861 });
862 }
863 } else if let Some(port_num) = port_value.as_u64() {
864 return Some(PortMapping {
865 host_port: None,
866 container_port: port_num as u16,
867 protocol: "tcp".to_string(),
868 exposed_to_host: false,
869 });
870 }
871
872 None
873}
874
875fn parse_volume_mount(volume_value: &serde_yaml::Value) -> Option<VolumeMount> {
877 if let Some(volume_str) = volume_value.as_str() {
878 let parts: Vec<&str> = volume_str.split(':').collect();
880 if parts.len() >= 2 {
881 return Some(VolumeMount {
882 source: parts[0].to_string(),
883 target: parts[1].to_string(),
884 mount_type: if parts[0].starts_with('/') || parts[0].starts_with('.') {
885 "bind".to_string()
886 } else {
887 "volume".to_string()
888 },
889 read_only: parts.get(2).is_some_and(|&opt| opt == "ro"),
890 });
891 }
892 }
893 None
894}
895
896fn parse_environment_variables(
898 env_value: &serde_yaml::Value,
899 env_map: &mut HashMap<String, String>,
900) {
901 if let Some(env_mapping) = env_value.as_mapping() {
902 for (key, value) in env_mapping {
903 if let Some(key_str) = key.as_str() {
904 let value_str = value.as_str().unwrap_or("").to_string();
905 env_map.insert(key_str.to_string(), value_str);
906 }
907 }
908 } else if let Some(env_sequence) = env_value.as_sequence() {
909 for env_item in env_sequence {
910 if let Some(env_str) = env_item.as_str() {
911 if let Some(eq_pos) = env_str.find('=') {
912 let key = env_str[..eq_pos].to_string();
913 let value = env_str[eq_pos + 1..].to_string();
914 env_map.insert(key, value);
915 } else {
916 env_map.insert(env_str.to_string(), String::new());
917 }
918 }
919 }
920 }
921}
922
923fn analyze_networking(
924 services: &[DockerService],
925 compose_files: &[ComposeFileInfo],
926) -> Result<NetworkingConfig> {
927 let mut custom_networks = Vec::new();
928 let mut connected_services: HashMap<String, Vec<String>> = HashMap::new();
929
930 for compose_file in compose_files {
932 for network_name in &compose_file.networks {
933 let network_info = NetworkInfo {
934 name: network_name.clone(),
935 driver: None, external: compose_file
937 .external_dependencies
938 .contains(&format!("network:{}", network_name)),
939 connected_services: Vec::new(),
940 };
941 custom_networks.push(network_info);
942 }
943 }
944
945 for service in services {
947 for network in &service.networks {
948 connected_services
949 .entry(network.clone())
950 .or_default()
951 .push(service.name.clone());
952 }
953 }
954
955 for network in &mut custom_networks {
957 if let Some(services) = connected_services.get(&network.name) {
958 network.connected_services = services.clone();
959 }
960 }
961
962 let service_discovery = ServiceDiscoveryConfig {
964 internal_dns: !services.is_empty(), external_tools: detect_service_discovery_tools(services),
966 service_mesh: detect_service_mesh(services),
967 };
968
969 let load_balancing = detect_load_balancers(services);
971
972 let external_connectivity = analyze_external_connectivity(services);
974
975 Ok(NetworkingConfig {
976 custom_networks,
977 service_discovery,
978 load_balancing,
979 external_connectivity,
980 })
981}
982
983fn determine_orchestration_pattern(
984 services: &[DockerService],
985 networking: &NetworkingConfig,
986) -> OrchestrationPattern {
987 if services.is_empty() {
988 return OrchestrationPattern::SingleContainer;
989 }
990
991 if services.len() == 1 {
992 return OrchestrationPattern::SingleContainer;
993 }
994
995 let has_multiple_backends = services
997 .iter()
998 .filter(|s| match &s.image_or_build {
999 ImageOrBuild::Image(img) => {
1000 !img.contains("nginx") && !img.contains("proxy") && !img.contains("traefik")
1001 }
1002 _ => true,
1003 })
1004 .count()
1005 > 2;
1006
1007 let has_service_discovery = networking.service_discovery.internal_dns
1008 || !networking.service_discovery.external_tools.is_empty();
1009
1010 let _has_load_balancing = !networking.load_balancing.is_empty();
1011
1012 let has_message_queues = services.iter().any(|s| match &s.image_or_build {
1013 ImageOrBuild::Image(img) => {
1014 img.contains("redis")
1015 || img.contains("rabbitmq")
1016 || img.contains("kafka")
1017 || img.contains("nats")
1018 }
1019 _ => false,
1020 });
1021
1022 if networking.service_discovery.service_mesh {
1023 OrchestrationPattern::ServiceMesh
1024 } else if has_message_queues && has_multiple_backends {
1025 OrchestrationPattern::EventDriven
1026 } else if has_multiple_backends && has_service_discovery {
1027 OrchestrationPattern::Microservices
1028 } else {
1029 OrchestrationPattern::DockerCompose
1030 }
1031}
1032
1033fn detect_service_discovery_tools(services: &[DockerService]) -> Vec<String> {
1035 let mut tools = Vec::new();
1036
1037 for service in services {
1038 if let ImageOrBuild::Image(image) = &service.image_or_build {
1039 if image.contains("consul") {
1040 tools.push("consul".to_string());
1041 }
1042 if image.contains("etcd") {
1043 tools.push("etcd".to_string());
1044 }
1045 if image.contains("zookeeper") {
1046 tools.push("zookeeper".to_string());
1047 }
1048 }
1049 }
1050
1051 tools.sort();
1052 tools.dedup();
1053 tools
1054}
1055
1056fn detect_service_mesh(services: &[DockerService]) -> bool {
1058 services.iter().any(|s| {
1059 if let ImageOrBuild::Image(image) = &s.image_or_build {
1060 image.contains("istio")
1061 || image.contains("linkerd")
1062 || image.contains("envoy")
1063 || image.contains("consul-connect")
1064 } else {
1065 false
1066 }
1067 })
1068}
1069
1070fn detect_load_balancers(services: &[DockerService]) -> Vec<LoadBalancerConfig> {
1072 let mut load_balancers = Vec::new();
1073
1074 for service in services {
1075 let is_load_balancer = match &service.image_or_build {
1077 ImageOrBuild::Image(image) => {
1078 image.contains("nginx")
1079 || image.contains("traefik")
1080 || image.contains("haproxy")
1081 || image.contains("envoy")
1082 || image.contains("kong")
1083 }
1084 _ => false,
1085 };
1086
1087 if is_load_balancer {
1088 let backends: Vec<String> = services
1090 .iter()
1091 .filter(|s| s.name != service.name && !service.depends_on.contains(&s.name))
1092 .map(|s| s.name.clone())
1093 .collect();
1094
1095 if !backends.is_empty() {
1096 let lb_type = match &service.image_or_build {
1097 ImageOrBuild::Image(image) => {
1098 if image.contains("nginx") {
1099 "nginx"
1100 } else if image.contains("traefik") {
1101 "traefik"
1102 } else if image.contains("haproxy") {
1103 "haproxy"
1104 } else if image.contains("envoy") {
1105 "envoy"
1106 } else if image.contains("kong") {
1107 "kong"
1108 } else {
1109 "unknown"
1110 }
1111 }
1112 _ => "unknown",
1113 };
1114
1115 load_balancers.push(LoadBalancerConfig {
1116 service: service.name.clone(),
1117 lb_type: lb_type.to_string(),
1118 backends,
1119 });
1120 }
1121 }
1122 }
1123
1124 load_balancers
1125}
1126
1127fn analyze_external_connectivity(services: &[DockerService]) -> ExternalConnectivity {
1129 let mut exposed_services = Vec::new();
1130 let mut ingress_patterns = Vec::new();
1131 let mut api_gateways = Vec::new();
1132
1133 for service in services {
1134 let mut external_ports = Vec::new();
1135 let mut protocols = Vec::new();
1136
1137 for port in &service.ports {
1139 if port.exposed_to_host {
1140 if let Some(host_port) = port.host_port {
1141 external_ports.push(host_port);
1142 }
1143 protocols.push(port.protocol.clone());
1144 }
1145 }
1146
1147 if !external_ports.is_empty() {
1148 let ssl_enabled = external_ports.contains(&443)
1150 || external_ports.contains(&8443)
1151 || service
1152 .environment
1153 .keys()
1154 .any(|k| k.to_lowercase().contains("ssl") || k.to_lowercase().contains("tls"));
1155
1156 exposed_services.push(ExposedService {
1157 service: service.name.clone(),
1158 external_ports,
1159 protocols: protocols
1160 .into_iter()
1161 .collect::<std::collections::HashSet<_>>()
1162 .into_iter()
1163 .collect(),
1164 ssl_enabled,
1165 });
1166 }
1167
1168 if service.name.to_lowercase().contains("gateway")
1170 || service.name.to_lowercase().contains("api")
1171 || service.name.to_lowercase().contains("proxy")
1172 {
1173 api_gateways.push(service.name.clone());
1174 }
1175
1176 if let ImageOrBuild::Image(image) = &service.image_or_build
1178 && (image.contains("kong")
1179 || image.contains("zuul")
1180 || image.contains("ambassador")
1181 || image.contains("traefik"))
1182 && !api_gateways.contains(&service.name)
1183 {
1184 api_gateways.push(service.name.clone());
1185 }
1186 }
1187
1188 if exposed_services.len() == 1 && api_gateways.len() == 1 {
1190 ingress_patterns.push("Single API Gateway".to_string());
1191 } else if exposed_services.len() > 1 && api_gateways.is_empty() {
1192 ingress_patterns.push("Multiple Direct Entry Points".to_string());
1193 } else if !api_gateways.is_empty() {
1194 ingress_patterns.push("API Gateway Pattern".to_string());
1195 }
1196
1197 let has_reverse_proxy = services.iter().any(|s| {
1199 if let ImageOrBuild::Image(image) = &s.image_or_build {
1200 image.contains("nginx") || image.contains("apache") || image.contains("caddy")
1201 } else {
1202 false
1203 }
1204 });
1205
1206 if has_reverse_proxy {
1207 ingress_patterns.push("Reverse Proxy".to_string());
1208 }
1209
1210 ExternalConnectivity {
1211 exposed_services,
1212 ingress_patterns,
1213 api_gateways,
1214 }
1215}
1216
1217fn analyze_environments(
1218 dockerfiles: &[DockerfileInfo],
1219 compose_files: &[ComposeFileInfo],
1220) -> Vec<DockerEnvironment> {
1221 let mut environments: HashMap<String, DockerEnvironment> = HashMap::new();
1222
1223 for dockerfile in dockerfiles {
1225 let env_name = dockerfile
1226 .environment
1227 .clone()
1228 .unwrap_or_else(|| "default".to_string());
1229 environments
1230 .entry(env_name.clone())
1231 .or_insert_with(|| DockerEnvironment {
1232 name: env_name,
1233 dockerfiles: Vec::new(),
1234 compose_files: Vec::new(),
1235 config_overrides: HashMap::new(),
1236 })
1237 .dockerfiles
1238 .push(dockerfile.path.clone());
1239 }
1240
1241 for compose_file in compose_files {
1243 let env_name = compose_file
1244 .environment
1245 .clone()
1246 .unwrap_or_else(|| "default".to_string());
1247 environments
1248 .entry(env_name.clone())
1249 .or_insert_with(|| DockerEnvironment {
1250 name: env_name,
1251 dockerfiles: Vec::new(),
1252 compose_files: Vec::new(),
1253 config_overrides: HashMap::new(),
1254 })
1255 .compose_files
1256 .push(compose_file.path.clone());
1257 }
1258
1259 environments.into_values().collect()
1260}
1261
1262fn suggest_service_name(dockerfile_path: &Path, project_root: &Path) -> String {
1272 let dockerfile_dir = dockerfile_path.parent().unwrap_or(dockerfile_path);
1274
1275 let name = if dockerfile_dir == project_root {
1277 project_root
1279 .file_name()
1280 .and_then(|n| n.to_str())
1281 .unwrap_or("app")
1282 } else {
1283 dockerfile_dir
1285 .file_name()
1286 .and_then(|n| n.to_str())
1287 .unwrap_or("app")
1288 };
1289
1290 sanitize_service_name(name)
1292}
1293
1294fn sanitize_service_name(name: &str) -> String {
1297 let sanitized: String = name
1298 .to_lowercase()
1299 .chars()
1300 .map(|c| {
1301 if c.is_ascii_alphanumeric() {
1302 c
1303 } else {
1304 '-'
1305 }
1306 })
1307 .collect();
1308
1309 let mut result = String::new();
1311 let mut prev_hyphen = true; for c in sanitized.chars() {
1314 if c == '-' {
1315 if !prev_hyphen {
1316 result.push(c);
1317 prev_hyphen = true;
1318 }
1319 } else {
1320 result.push(c);
1321 prev_hyphen = false;
1322 }
1323 }
1324
1325 if result.ends_with('-') {
1327 result.pop();
1328 }
1329
1330 if result.is_empty() {
1331 "app".to_string()
1332 } else {
1333 result
1334 }
1335}
1336
1337fn compute_build_context(dockerfile_path: &Path, project_root: &Path) -> String {
1342 let dockerfile_dir = dockerfile_path.parent().unwrap_or(dockerfile_path);
1343
1344 if let Ok(relative) = dockerfile_dir.strip_prefix(project_root) {
1346 let path_str = relative.to_string_lossy().to_string();
1347 if path_str.is_empty() {
1348 ".".to_string()
1349 } else {
1350 path_str
1351 }
1352 } else {
1353 ".".to_string()
1355 }
1356}
1357
1358fn infer_default_port(base_image: &Option<String>) -> Option<u16> {
1362 let image = base_image.as_ref()?;
1363 let image_lower = image.to_lowercase();
1364
1365 let image_name = image_lower
1367 .split('/')
1368 .last()
1369 .unwrap_or(&image_lower)
1370 .split(':')
1371 .next()
1372 .unwrap_or(&image_lower);
1373
1374 match image_name {
1375 s if s.starts_with("node") => Some(3000),
1377 s if s.contains("python") => Some(8000),
1379 s if s.contains("flask") => Some(5000),
1380 s if s.contains("django") => Some(8000),
1381 s if s.contains("fastapi") => Some(8000),
1382 s if s.starts_with("golang") || s.starts_with("go") => Some(8080),
1384 s if s.starts_with("rust") => Some(8080),
1386 s if s.starts_with("nginx") => Some(80),
1388 s if s.starts_with("httpd") || s.starts_with("apache") => Some(80),
1389 s if s.starts_with("caddy") => Some(80),
1390 s if s.contains("openjdk") || s.contains("java") => Some(8080),
1392 s if s.contains("tomcat") => Some(8080),
1393 s if s.contains("spring") => Some(8080),
1394 s if s.starts_with("ruby") => Some(3000),
1396 s if s.contains("rails") => Some(3000),
1397 s if s.starts_with("php") => Some(80),
1399 s if s.contains("dotnet") || s.contains("aspnet") => Some(80),
1401 s if s.contains("elixir") || s.contains("phoenix") => Some(4000),
1403 _ => None,
1405 }
1406}
1407
1408pub fn discover_dockerfiles_for_deployment(
1422 project_root: &Path,
1423) -> Result<Vec<DiscoveredDockerfile>> {
1424 let dockerfiles = find_dockerfiles(project_root)?;
1425
1426 let discovered: Vec<DiscoveredDockerfile> = dockerfiles
1427 .into_iter()
1428 .filter_map(|path| {
1429 let info = parse_dockerfile(&path).ok()?;
1430 let build_context = compute_build_context(&path, project_root);
1431 let suggested_name = suggest_service_name(&path, project_root);
1432
1433 let suggested_port = info
1435 .exposed_ports
1436 .first()
1437 .copied()
1438 .or_else(|| infer_default_port(&info.base_image));
1439
1440 Some(DiscoveredDockerfile {
1441 path,
1442 build_context,
1443 suggested_service_name: suggested_name,
1444 suggested_port,
1445 base_image: info.base_image,
1446 is_multistage: info.is_multistage,
1447 environment: info.environment,
1448 })
1449 })
1450 .collect();
1451
1452 Ok(discovered)
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use super::*;
1458
1459 #[test]
1460 fn test_is_dockerfile_name() {
1461 assert!(is_dockerfile_name("Dockerfile"));
1462 assert!(is_dockerfile_name("dockerfile"));
1463 assert!(is_dockerfile_name("Dockerfile.dev"));
1464 assert!(is_dockerfile_name("dockerfile.prod"));
1465 assert!(is_dockerfile_name("api.dockerfile"));
1466 assert!(!is_dockerfile_name("README.md"));
1467 assert!(!is_dockerfile_name("package.json"));
1468 }
1469
1470 #[test]
1471 fn test_is_compose_file_name() {
1472 assert!(is_compose_file_name("docker-compose.yml"));
1473 assert!(is_compose_file_name("docker-compose.yaml"));
1474 assert!(is_compose_file_name("docker-compose.dev.yml"));
1475 assert!(is_compose_file_name("docker-compose.prod.yaml"));
1476 assert!(is_compose_file_name("compose.yml"));
1477 assert!(is_compose_file_name("compose.yaml"));
1478 assert!(!is_compose_file_name("README.md"));
1479 assert!(!is_compose_file_name("package.json"));
1480 }
1481
1482 #[test]
1483 fn test_extract_environment_from_filename() {
1484 assert_eq!(
1485 extract_environment_from_filename(&PathBuf::from("Dockerfile.dev")),
1486 Some("development".to_string())
1487 );
1488 assert_eq!(
1489 extract_environment_from_filename(&PathBuf::from("docker-compose.prod.yml")),
1490 Some("production".to_string())
1491 );
1492 assert_eq!(
1493 extract_environment_from_filename(&PathBuf::from("Dockerfile")),
1494 None
1495 );
1496 }
1497
1498 #[test]
1503 fn test_suggest_service_name_from_subdirectory() {
1504 let path = PathBuf::from("/project/services/api/Dockerfile");
1505 let root = PathBuf::from("/project");
1506 assert_eq!(suggest_service_name(&path, &root), "api");
1507 }
1508
1509 #[test]
1510 fn test_suggest_service_name_from_root() {
1511 let path = PathBuf::from("/project/Dockerfile");
1512 let root = PathBuf::from("/project");
1513 assert_eq!(suggest_service_name(&path, &root), "project");
1514 }
1515
1516 #[test]
1517 fn test_suggest_service_name_nested() {
1518 let path = PathBuf::from("/myapp/apps/web-frontend/Dockerfile");
1519 let root = PathBuf::from("/myapp");
1520 assert_eq!(suggest_service_name(&path, &root), "web-frontend");
1521 }
1522
1523 #[test]
1524 fn test_suggest_service_name_sanitizes() {
1525 let path = PathBuf::from("/project/my_service_api/Dockerfile");
1527 let root = PathBuf::from("/project");
1528 assert_eq!(suggest_service_name(&path, &root), "my-service-api");
1529 }
1530
1531 #[test]
1532 fn test_sanitize_service_name() {
1533 assert_eq!(sanitize_service_name("My_Service"), "my-service");
1534 assert_eq!(sanitize_service_name("api-v2"), "api-v2");
1535 assert_eq!(sanitize_service_name("__leading__"), "leading");
1536 assert_eq!(sanitize_service_name("trailing--"), "trailing");
1537 assert_eq!(sanitize_service_name("multi---hyphens"), "multi-hyphens");
1538 assert_eq!(sanitize_service_name("special@#chars!"), "special-chars");
1539 assert_eq!(sanitize_service_name(""), "app"); }
1541
1542 #[test]
1543 fn test_compute_build_context_subdirectory() {
1544 let path = PathBuf::from("/project/services/api/Dockerfile");
1545 let root = PathBuf::from("/project");
1546 assert_eq!(compute_build_context(&path, &root), "services/api");
1547 }
1548
1549 #[test]
1550 fn test_compute_build_context_root() {
1551 let path = PathBuf::from("/project/Dockerfile");
1552 let root = PathBuf::from("/project");
1553 assert_eq!(compute_build_context(&path, &root), ".");
1554 }
1555
1556 #[test]
1557 fn test_compute_build_context_deep_nested() {
1558 let path = PathBuf::from("/myapp/packages/frontend/apps/web/Dockerfile");
1559 let root = PathBuf::from("/myapp");
1560 assert_eq!(
1561 compute_build_context(&path, &root),
1562 "packages/frontend/apps/web"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_infer_default_port_node() {
1568 assert_eq!(infer_default_port(&Some("node:18".to_string())), Some(3000));
1569 assert_eq!(
1570 infer_default_port(&Some("node:18-alpine".to_string())),
1571 Some(3000)
1572 );
1573 }
1574
1575 #[test]
1576 fn test_infer_default_port_nginx() {
1577 assert_eq!(
1578 infer_default_port(&Some("nginx:latest".to_string())),
1579 Some(80)
1580 );
1581 assert_eq!(
1582 infer_default_port(&Some("nginx:1.25-alpine".to_string())),
1583 Some(80)
1584 );
1585 }
1586
1587 #[test]
1588 fn test_infer_default_port_python() {
1589 assert_eq!(
1590 infer_default_port(&Some("python:3.11".to_string())),
1591 Some(8000)
1592 );
1593 }
1594
1595 #[test]
1596 fn test_infer_default_port_go() {
1597 assert_eq!(
1598 infer_default_port(&Some("golang:1.21".to_string())),
1599 Some(8080)
1600 );
1601 }
1602
1603 #[test]
1604 fn test_infer_default_port_java() {
1605 assert_eq!(
1606 infer_default_port(&Some("openjdk:17".to_string())),
1607 Some(8080)
1608 );
1609 }
1610
1611 #[test]
1612 fn test_infer_default_port_ruby() {
1613 assert_eq!(
1614 infer_default_port(&Some("ruby:3.2".to_string())),
1615 Some(3000)
1616 );
1617 }
1618
1619 #[test]
1620 fn test_infer_default_port_with_registry() {
1621 assert_eq!(
1623 infer_default_port(&Some("gcr.io/my-project/node:18".to_string())),
1624 Some(3000)
1625 );
1626 assert_eq!(
1627 infer_default_port(&Some("docker.io/library/nginx:latest".to_string())),
1628 Some(80)
1629 );
1630 }
1631
1632 #[test]
1633 fn test_infer_default_port_unknown() {
1634 assert_eq!(
1635 infer_default_port(&Some("custom-base:latest".to_string())),
1636 None
1637 );
1638 assert_eq!(infer_default_port(&None), None);
1639 }
1640}