Skip to content

Commit aceeef7

Browse files
Alex Holmbergclaude
authored andcommitted
feat(11.3-01): add infrastructure presence detection
Add detection for existing infrastructure configurations: - Kubernetes manifests (k8s/, deploy/, manifests/ directories) - Helm charts (Chart.yaml detection) - Terraform files (*.tf) - Docker Compose files - Syncable deployment configs Includes InfrastructurePresence struct with has_any() and detected_types() helper methods. Detection is integrated into analyze_project_with_config. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 153ec3b commit aceeef7

3 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
//! Infrastructure detection for deployment recommendations.
2+
//!
3+
//! Detects existing infrastructure configurations:
4+
//! - Kubernetes manifests (k8s/, deploy/, manifests/)
5+
//! - Helm charts (Chart.yaml)
6+
//! - Terraform files (*.tf)
7+
//! - Docker Compose files
8+
//! - Syncable deployment configs (.syncable/)
9+
10+
use crate::analyzer::InfrastructurePresence;
11+
use crate::common::file_utils::is_readable_file;
12+
use std::path::{Path, PathBuf};
13+
14+
/// Common directories where K8s manifests might be found
15+
const K8S_DIRECTORIES: &[&str] = &[
16+
"k8s",
17+
"kubernetes",
18+
"deploy",
19+
"deployment",
20+
"deployments",
21+
"manifests",
22+
"kube",
23+
"charts",
24+
".k8s",
25+
];
26+
27+
/// Docker compose file variants
28+
const COMPOSE_FILES: &[&str] = &[
29+
"docker-compose.yml",
30+
"docker-compose.yaml",
31+
"compose.yml",
32+
"compose.yaml",
33+
"docker-compose.dev.yml",
34+
"docker-compose.prod.yml",
35+
"docker-compose.local.yml",
36+
];
37+
38+
/// Detect infrastructure presence in a project
39+
pub fn detect_infrastructure(project_root: &Path) -> InfrastructurePresence {
40+
let mut infra = InfrastructurePresence::default();
41+
42+
// Detect Docker Compose
43+
for compose_file in COMPOSE_FILES {
44+
if is_readable_file(&project_root.join(compose_file)) {
45+
infra.has_docker_compose = true;
46+
break;
47+
}
48+
}
49+
50+
// Detect Kubernetes manifests
51+
let k8s_paths = detect_kubernetes_manifests(project_root);
52+
if !k8s_paths.is_empty() {
53+
infra.has_kubernetes = true;
54+
infra.kubernetes_paths = k8s_paths;
55+
}
56+
57+
// Detect Helm charts
58+
let helm_paths = detect_helm_charts(project_root);
59+
if !helm_paths.is_empty() {
60+
infra.has_helm = true;
61+
infra.helm_chart_paths = helm_paths;
62+
}
63+
64+
// Detect Terraform
65+
let tf_paths = detect_terraform(project_root);
66+
if !tf_paths.is_empty() {
67+
infra.has_terraform = true;
68+
infra.terraform_paths = tf_paths;
69+
}
70+
71+
// Detect Syncable deployment config
72+
infra.has_deployment_config = project_root.join(".syncable").is_dir()
73+
|| is_readable_file(&project_root.join("syncable.json"))
74+
|| is_readable_file(&project_root.join("syncable.yaml"))
75+
|| is_readable_file(&project_root.join("syncable.yml"));
76+
77+
// Generate summary
78+
if infra.has_any() {
79+
let types = infra.detected_types();
80+
infra.summary = Some(format!("Detected: {}", types.join(", ")));
81+
}
82+
83+
infra
84+
}
85+
86+
/// Detect Kubernetes manifest directories and files
87+
fn detect_kubernetes_manifests(project_root: &Path) -> Vec<PathBuf> {
88+
let mut paths = Vec::new();
89+
90+
// Check common K8s directories
91+
for dir_name in K8S_DIRECTORIES {
92+
let dir_path = project_root.join(dir_name);
93+
if dir_path.is_dir() && has_kubernetes_files(&dir_path) {
94+
paths.push(dir_path);
95+
}
96+
}
97+
98+
// Check root-level YAML files that might be K8s manifests
99+
if let Ok(entries) = std::fs::read_dir(project_root) {
100+
for entry in entries.flatten() {
101+
let path = entry.path();
102+
if path.is_file() {
103+
if let Some(ext) = path.extension() {
104+
if (ext == "yaml" || ext == "yml") && is_kubernetes_manifest(&path) {
105+
paths.push(path);
106+
}
107+
}
108+
}
109+
}
110+
}
111+
112+
paths
113+
}
114+
115+
/// Check if a directory contains Kubernetes files
116+
fn has_kubernetes_files(dir: &Path) -> bool {
117+
if let Ok(entries) = std::fs::read_dir(dir) {
118+
for entry in entries.flatten() {
119+
let path = entry.path();
120+
if path.is_file() {
121+
if let Some(ext) = path.extension() {
122+
if (ext == "yaml" || ext == "yml") && is_kubernetes_manifest(&path) {
123+
return true;
124+
}
125+
}
126+
}
127+
}
128+
}
129+
false
130+
}
131+
132+
/// Check if a YAML file is a Kubernetes manifest (quick check without full parsing)
133+
fn is_kubernetes_manifest(path: &Path) -> bool {
134+
if let Ok(content) = std::fs::read_to_string(path) {
135+
// Check first 2KB of file for K8s markers (fast check)
136+
let check_content = if content.len() > 2048 {
137+
&content[..2048]
138+
} else {
139+
&content
140+
};
141+
142+
// K8s manifest indicators
143+
let k8s_kinds = [
144+
"kind: Deployment",
145+
"kind: Service",
146+
"kind: Pod",
147+
"kind: ConfigMap",
148+
"kind: Secret",
149+
"kind: Ingress",
150+
"kind: StatefulSet",
151+
"kind: DaemonSet",
152+
"kind: Job",
153+
"kind: CronJob",
154+
"kind: PersistentVolumeClaim",
155+
"kind: ServiceAccount",
156+
"kind: Role",
157+
"kind: RoleBinding",
158+
"kind: ClusterRole",
159+
"kind: ClusterRoleBinding",
160+
"kind: NetworkPolicy",
161+
"kind: HorizontalPodAutoscaler",
162+
"kind: PodDisruptionBudget",
163+
"kind: Namespace",
164+
];
165+
166+
// Check for apiVersion + kind pattern (most K8s manifests)
167+
if check_content.contains("apiVersion:") {
168+
for kind in &k8s_kinds {
169+
if check_content.contains(*kind) {
170+
return true;
171+
}
172+
}
173+
}
174+
}
175+
false
176+
}
177+
178+
/// Detect Helm chart directories
179+
fn detect_helm_charts(project_root: &Path) -> Vec<PathBuf> {
180+
let mut paths = Vec::new();
181+
182+
// Check if root is a Helm chart
183+
if is_readable_file(&project_root.join("Chart.yaml")) {
184+
paths.push(project_root.to_path_buf());
185+
}
186+
187+
// Check common locations
188+
let helm_locations = ["charts", "helm", "deploy/helm", "deployment/helm"];
189+
for location in &helm_locations {
190+
let dir = project_root.join(location);
191+
if dir.is_dir() {
192+
// Check if it's a chart itself
193+
if is_readable_file(&dir.join("Chart.yaml")) {
194+
paths.push(dir.clone());
195+
}
196+
// Check subdirectories for charts
197+
if let Ok(entries) = std::fs::read_dir(&dir) {
198+
for entry in entries.flatten() {
199+
let path = entry.path();
200+
if path.is_dir() && is_readable_file(&path.join("Chart.yaml")) {
201+
paths.push(path);
202+
}
203+
}
204+
}
205+
}
206+
}
207+
208+
paths
209+
}
210+
211+
/// Detect Terraform directories
212+
fn detect_terraform(project_root: &Path) -> Vec<PathBuf> {
213+
let mut paths = Vec::new();
214+
215+
// Check common Terraform locations
216+
let tf_locations = ["terraform", "infra", "infrastructure", "tf", "iac"];
217+
for location in &tf_locations {
218+
let dir = project_root.join(location);
219+
if dir.is_dir() && has_terraform_files(&dir) {
220+
paths.push(dir);
221+
}
222+
}
223+
224+
// Check root for Terraform files
225+
if has_terraform_files(project_root) {
226+
paths.push(project_root.to_path_buf());
227+
}
228+
229+
paths
230+
}
231+
232+
/// Check if a directory contains Terraform files
233+
fn has_terraform_files(dir: &Path) -> bool {
234+
if let Ok(entries) = std::fs::read_dir(dir) {
235+
for entry in entries.flatten() {
236+
let path = entry.path();
237+
if path.is_file() {
238+
if let Some(ext) = path.extension() {
239+
if ext == "tf" {
240+
return true;
241+
}
242+
}
243+
}
244+
}
245+
}
246+
false
247+
}
248+
249+
#[cfg(test)]
250+
mod tests {
251+
use super::*;
252+
use std::fs;
253+
use tempfile::TempDir;
254+
255+
#[test]
256+
fn test_detect_empty_project() {
257+
let temp_dir = TempDir::new().unwrap();
258+
let infra = detect_infrastructure(temp_dir.path());
259+
assert!(!infra.has_any());
260+
}
261+
262+
#[test]
263+
fn test_detect_docker_compose() {
264+
let temp_dir = TempDir::new().unwrap();
265+
fs::write(temp_dir.path().join("docker-compose.yml"), "version: '3'\nservices:\n app:\n build: .").unwrap();
266+
267+
let infra = detect_infrastructure(temp_dir.path());
268+
assert!(infra.has_docker_compose);
269+
assert!(infra.has_any());
270+
}
271+
272+
#[test]
273+
fn test_detect_kubernetes_manifest() {
274+
let temp_dir = TempDir::new().unwrap();
275+
let k8s_dir = temp_dir.path().join("k8s");
276+
fs::create_dir(&k8s_dir).unwrap();
277+
fs::write(k8s_dir.join("deployment.yaml"), "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: test").unwrap();
278+
279+
let infra = detect_infrastructure(temp_dir.path());
280+
assert!(infra.has_kubernetes);
281+
assert_eq!(infra.kubernetes_paths.len(), 1);
282+
}
283+
284+
#[test]
285+
fn test_detect_helm_chart() {
286+
let temp_dir = TempDir::new().unwrap();
287+
let helm_dir = temp_dir.path().join("charts").join("myapp");
288+
fs::create_dir_all(&helm_dir).unwrap();
289+
fs::write(helm_dir.join("Chart.yaml"), "apiVersion: v2\nname: myapp\nversion: 1.0.0").unwrap();
290+
291+
let infra = detect_infrastructure(temp_dir.path());
292+
assert!(infra.has_helm);
293+
assert!(!infra.helm_chart_paths.is_empty());
294+
}
295+
296+
#[test]
297+
fn test_detect_terraform() {
298+
let temp_dir = TempDir::new().unwrap();
299+
let tf_dir = temp_dir.path().join("terraform");
300+
fs::create_dir(&tf_dir).unwrap();
301+
fs::write(tf_dir.join("main.tf"), "provider \"aws\" {\n region = \"us-east-1\"\n}").unwrap();
302+
303+
let infra = detect_infrastructure(temp_dir.path());
304+
assert!(infra.has_terraform);
305+
assert!(!infra.terraform_paths.is_empty());
306+
}
307+
308+
#[test]
309+
fn test_detect_syncable_config() {
310+
let temp_dir = TempDir::new().unwrap();
311+
let syncable_dir = temp_dir.path().join(".syncable");
312+
fs::create_dir(&syncable_dir).unwrap();
313+
314+
let infra = detect_infrastructure(temp_dir.path());
315+
assert!(infra.has_deployment_config);
316+
}
317+
318+
#[test]
319+
fn test_infrastructure_summary() {
320+
let temp_dir = TempDir::new().unwrap();
321+
fs::write(temp_dir.path().join("docker-compose.yml"), "version: '3'").unwrap();
322+
let tf_dir = temp_dir.path().join("terraform");
323+
fs::create_dir(&tf_dir).unwrap();
324+
fs::write(tf_dir.join("main.tf"), "provider \"aws\" {}").unwrap();
325+
326+
let infra = detect_infrastructure(temp_dir.path());
327+
assert!(infra.has_docker_compose);
328+
assert!(infra.has_terraform);
329+
assert!(infra.summary.is_some());
330+
let summary = infra.summary.unwrap();
331+
assert!(summary.contains("Docker Compose"));
332+
assert!(summary.contains("Terraform"));
333+
}
334+
}

src/analyzer/context/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ pub mod analysis;
22
pub(crate) mod file_analyzers;
33
pub(crate) mod health_detector;
44
pub(crate) mod helpers;
5+
pub(crate) mod infra_detector;
56
pub(crate) mod language_analyzers;
67
pub(crate) mod microservices;
78
pub(crate) mod project_type;
89
pub(crate) mod tech_specific;
910

1011
pub use analysis::analyze_context;
1112
pub use health_detector::detect_health_endpoints;
13+
pub use infra_detector::detect_infrastructure;

0 commit comments

Comments
 (0)