Skip to content

Commit 407684a

Browse files
Alex Holmbergclaude
authored andcommitted
feat(analyzer): add dockerfile discovery for deployment wizard
- Add DiscoveredDockerfile type with deployment-focused fields - Add suggest_service_name() with sanitization for K8s service names - Add compute_build_context() for relative path computation - Add infer_default_port() for common base images - Add discover_dockerfiles_for_deployment() main discovery function - Export new types from analyzer module - Add 16 unit tests for discovery functions Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6222001 commit 407684a

2 files changed

Lines changed: 361 additions & 2 deletions

File tree

‎src/analyzer/docker_analyzer.rs‎

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,28 @@ pub struct DockerfileInfo {
5656
pub instruction_count: usize,
5757
}
5858

59+
/// Dockerfile discovery result for deployment wizard
60+
///
61+
/// Provides deployment-focused metadata about a Dockerfile including
62+
/// build context path, suggested service name, and port configuration.
63+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64+
pub struct DiscoveredDockerfile {
65+
/// Absolute path to the Dockerfile
66+
pub path: PathBuf,
67+
/// Relative path from project root to Dockerfile directory (build context)
68+
pub build_context: String,
69+
/// Suggested service name based on directory structure
70+
pub suggested_service_name: String,
71+
/// Suggested port for deployment (from EXPOSE or default)
72+
pub suggested_port: Option<u16>,
73+
/// Base image from Dockerfile
74+
pub base_image: Option<String>,
75+
/// Whether this is a multi-stage build
76+
pub is_multistage: bool,
77+
/// Environment type (dev, prod, staging) from filename
78+
pub environment: Option<String>,
79+
}
80+
5981
/// Information about a Docker Compose file
6082
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6183
pub struct ComposeFileInfo {
@@ -1237,6 +1259,199 @@ fn analyze_environments(
12371259
environments.into_values().collect()
12381260
}
12391261

1262+
// =============================================================================
1263+
// Dockerfile Discovery for Deployment Wizard
1264+
// =============================================================================
1265+
1266+
/// Suggests a service name based on Dockerfile path and project structure.
1267+
///
1268+
/// Uses the parent directory name if not at project root, otherwise uses
1269+
/// the project root's directory name. The name is sanitized to be lowercase
1270+
/// with hyphens (suitable for Kubernetes service names).
1271+
fn suggest_service_name(dockerfile_path: &Path, project_root: &Path) -> String {
1272+
// Get parent directory of Dockerfile
1273+
let dockerfile_dir = dockerfile_path.parent().unwrap_or(dockerfile_path);
1274+
1275+
// Determine which directory name to use
1276+
let name = if dockerfile_dir == project_root {
1277+
// Dockerfile is in project root - use project root's directory name
1278+
project_root
1279+
.file_name()
1280+
.and_then(|n| n.to_str())
1281+
.unwrap_or("app")
1282+
} else {
1283+
// Use the immediate parent directory name
1284+
dockerfile_dir
1285+
.file_name()
1286+
.and_then(|n| n.to_str())
1287+
.unwrap_or("app")
1288+
};
1289+
1290+
// Sanitize: lowercase, replace underscores/spaces with hyphens, remove non-alphanumeric
1291+
sanitize_service_name(name)
1292+
}
1293+
1294+
/// Sanitizes a string to be a valid Kubernetes service name.
1295+
/// Lowercase, alphanumeric with hyphens, no leading/trailing hyphens.
1296+
fn 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+
// Remove consecutive hyphens and trim hyphens from ends
1310+
let mut result = String::new();
1311+
let mut prev_hyphen = true; // Start true to skip leading hyphens
1312+
1313+
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+
// Remove trailing hyphen
1326+
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+
1337+
/// Computes build context path relative to project root.
1338+
///
1339+
/// Returns the relative path from project root to the Dockerfile's directory,
1340+
/// suitable for use as a Docker build context path.
1341+
fn compute_build_context(dockerfile_path: &Path, project_root: &Path) -> String {
1342+
let dockerfile_dir = dockerfile_path.parent().unwrap_or(dockerfile_path);
1343+
1344+
// Try to get relative path from project root to dockerfile directory
1345+
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+
// Fallback: use "." if we can't compute relative path
1354+
".".to_string()
1355+
}
1356+
}
1357+
1358+
/// Infers default port based on base image.
1359+
///
1360+
/// Returns a common default port for well-known base images.
1361+
fn 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+
// Extract image name without registry/tag
1366+
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+
// Node.js
1376+
s if s.starts_with("node") => Some(3000),
1377+
// Python web frameworks
1378+
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+
// Go
1383+
s if s.starts_with("golang") || s.starts_with("go") => Some(8080),
1384+
// Rust
1385+
s if s.starts_with("rust") => Some(8080),
1386+
// Web servers
1387+
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+
// Java
1391+
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+
// Ruby
1395+
s if s.starts_with("ruby") => Some(3000),
1396+
s if s.contains("rails") => Some(3000),
1397+
// PHP
1398+
s if s.starts_with("php") => Some(80),
1399+
// .NET
1400+
s if s.contains("dotnet") || s.contains("aspnet") => Some(80),
1401+
// Elixir/Phoenix
1402+
s if s.contains("elixir") || s.contains("phoenix") => Some(4000),
1403+
// Default: no inference
1404+
_ => None,
1405+
}
1406+
}
1407+
1408+
/// Discovers Dockerfiles in a project and returns deployment-focused metadata.
1409+
///
1410+
/// This function finds all Dockerfiles in the project, parses them, and returns
1411+
/// deployment-relevant information including build context paths, suggested
1412+
/// service names, and port configurations.
1413+
///
1414+
/// # Arguments
1415+
///
1416+
/// * `project_root` - The root directory of the project to analyze
1417+
///
1418+
/// # Returns
1419+
///
1420+
/// A vector of `DiscoveredDockerfile` structs, one for each Dockerfile found
1421+
pub 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+
// Get port from EXPOSE instruction or infer from base image
1434+
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+
12401455
#[cfg(test)]
12411456
mod tests {
12421457
use super::*;
@@ -1279,4 +1494,147 @@ mod tests {
12791494
None
12801495
);
12811496
}
1497+
1498+
// =============================================================================
1499+
// Dockerfile Discovery Tests
1500+
// =============================================================================
1501+
1502+
#[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+
// Underscores become hyphens
1526+
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"); // Empty defaults to "app"
1540+
}
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+
// Should handle images with registry prefix
1622+
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+
}
12821640
}

‎src/analyzer/mod.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ pub use monorepo::{MonorepoDetectionConfig, analyze_monorepo, analyze_monorepo_w
6363

6464
// Re-export Docker analysis types
6565
pub use docker_analyzer::{
66-
ComposeFileInfo, DockerAnalysis, DockerEnvironment, DockerService, DockerfileInfo,
67-
NetworkingConfig, OrchestrationPattern, analyze_docker_infrastructure,
66+
ComposeFileInfo, DiscoveredDockerfile, DockerAnalysis, DockerEnvironment, DockerService,
67+
DockerfileInfo, NetworkingConfig, OrchestrationPattern, analyze_docker_infrastructure,
68+
discover_dockerfiles_for_deployment,
6869
};
6970

7071
/// Represents a detected programming language

0 commit comments

Comments
 (0)