syncable_cli/analyzer/k8s_optimize/parser/
terraform.rs1use super::yaml::{detect_workload_type, parse_cpu_to_millicores, parse_memory_to_bytes};
7use crate::analyzer::k8s_optimize::types::WorkloadType;
8use hcl::{self, Block, Body};
9use std::path::Path;
10
11#[derive(Debug, Clone)]
13pub struct TfResourceSpec {
14 pub cpu: Option<u64>,
16 pub memory: Option<u64>,
18}
19
20#[derive(Debug, Clone)]
22pub struct TerraformK8sResource {
23 pub resource_type: String,
25 pub tf_name: String,
27 pub k8s_name: Option<String>,
29 pub namespace: Option<String>,
31 pub workload_type: WorkloadType,
33 pub containers: Vec<TerraformContainer>,
35 pub source_file: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct TerraformContainer {
42 pub name: String,
44 pub image: Option<String>,
46 pub requests: Option<TfResourceSpec>,
48 pub limits: Option<TfResourceSpec>,
50}
51
52pub fn parse_terraform_k8s_resources(path: &Path) -> Vec<TerraformK8sResource> {
54 let mut resources = Vec::new();
55
56 if path.is_file() {
57 if let Some(ext) = path.extension()
58 && ext == "tf"
59 && let Ok(content) = std::fs::read_to_string(path)
60 {
61 resources.extend(parse_tf_content(&content, path));
62 }
63 } else if path.is_dir()
64 && let Ok(entries) = std::fs::read_dir(path)
65 {
66 for entry in entries.flatten() {
67 let entry_path = entry.path();
68 if entry_path.is_file()
69 && let Some(ext) = entry_path.extension()
70 && ext == "tf"
71 && let Ok(content) = std::fs::read_to_string(&entry_path)
72 {
73 resources.extend(parse_tf_content(&content, &entry_path));
74 }
75 }
76 }
77
78 resources
79}
80
81fn parse_tf_content(content: &str, file_path: &Path) -> Vec<TerraformK8sResource> {
83 let mut resources = Vec::new();
84
85 let body: Result<Body, _> = hcl::from_str(content);
87 let body = match body {
88 Ok(b) => b,
89 Err(e) => {
90 log::debug!("Failed to parse HCL in {:?}: {}", file_path, e);
91 return resources;
92 }
93 };
94
95 for structure in body.iter() {
97 if let hcl::Structure::Block(block) = structure
98 && block.identifier() == "resource"
99 && let Some(resource) = parse_resource_block(block, file_path)
100 {
101 resources.push(resource);
102 }
103 }
104
105 resources
106}
107
108const K8S_RESOURCE_TYPES: &[&str] = &[
110 "kubernetes_deployment",
111 "kubernetes_deployment_v1",
112 "kubernetes_stateful_set",
113 "kubernetes_stateful_set_v1",
114 "kubernetes_daemon_set",
115 "kubernetes_daemon_set_v1",
116 "kubernetes_replication_controller",
117 "kubernetes_replication_controller_v1",
118 "kubernetes_job",
119 "kubernetes_job_v1",
120 "kubernetes_cron_job",
121 "kubernetes_cron_job_v1",
122 "kubernetes_pod",
123 "kubernetes_pod_v1",
124];
125
126fn parse_resource_block(block: &Block, file_path: &Path) -> Option<TerraformK8sResource> {
128 let labels: Vec<&str> = block.labels().iter().map(|l| l.as_str()).collect();
129
130 if labels.len() < 2 {
131 return None;
132 }
133
134 let resource_type = labels[0];
135 let tf_name = labels[1];
136
137 if !K8S_RESOURCE_TYPES.contains(&resource_type) {
139 return None;
140 }
141
142 let mut k8s_name = None;
143 let mut namespace = None;
144 let mut containers = Vec::new();
145
146 for attr_or_block in block.body().iter() {
148 if let hcl::Structure::Block(inner_block) = attr_or_block {
149 match inner_block.identifier() {
150 "metadata" => {
151 (k8s_name, namespace) = parse_metadata_block(inner_block);
152 }
153 "spec" => {
154 containers = parse_spec_block(inner_block, resource_type);
155 }
156 _ => {}
157 }
158 }
159 }
160
161 let image = containers.first().and_then(|c| c.image.as_deref());
163 let container_name = containers.first().map(|c| c.name.as_str());
164 let kind = match resource_type {
165 t if t.contains("deployment") => "Deployment",
166 t if t.contains("stateful_set") => "StatefulSet",
167 t if t.contains("daemon_set") => "DaemonSet",
168 t if t.contains("job") => "Job",
169 t if t.contains("cron_job") => "CronJob",
170 t if t.contains("pod") => "Pod",
171 _ => "Deployment",
172 };
173 let workload_type = detect_workload_type(image, container_name, kind);
174
175 Some(TerraformK8sResource {
176 resource_type: resource_type.to_string(),
177 tf_name: tf_name.to_string(),
178 k8s_name,
179 namespace,
180 workload_type,
181 containers,
182 source_file: file_path.to_string_lossy().to_string(),
183 })
184}
185
186fn parse_metadata_block(block: &Block) -> (Option<String>, Option<String>) {
188 let mut name = None;
189 let mut namespace = None;
190
191 for structure in block.body().iter() {
192 if let hcl::Structure::Attribute(attr) = structure {
193 match attr.key() {
194 "name" => {
195 name = expr_to_string(attr.expr());
196 }
197 "namespace" => {
198 namespace = expr_to_string(attr.expr());
199 }
200 _ => {}
201 }
202 }
203 }
204
205 (name, namespace)
206}
207
208fn parse_spec_block(block: &Block, resource_type: &str) -> Vec<TerraformContainer> {
210 let mut containers = Vec::new();
211
212 for structure in block.body().iter() {
215 if let hcl::Structure::Block(inner) = structure {
216 match inner.identifier() {
217 "template" => {
218 containers.extend(parse_template_block(inner));
219 }
220 "container" => {
221 if let Some(c) = parse_container_block(inner) {
223 containers.push(c);
224 }
225 }
226 "spec" if resource_type.contains("pod") => {
227 for s in inner.body().iter() {
229 if let hcl::Structure::Block(container_block) = s
230 && container_block.identifier() == "container"
231 && let Some(c) = parse_container_block(container_block)
232 {
233 containers.push(c);
234 }
235 }
236 }
237 _ => {}
238 }
239 }
240 }
241
242 containers
243}
244
245fn parse_template_block(block: &Block) -> Vec<TerraformContainer> {
247 let mut containers = Vec::new();
248
249 for structure in block.body().iter() {
250 if let hcl::Structure::Block(inner) = structure
251 && inner.identifier() == "spec"
252 {
253 for s in inner.body().iter() {
254 if let hcl::Structure::Block(container_block) = s
255 && container_block.identifier() == "container"
256 && let Some(c) = parse_container_block(container_block)
257 {
258 containers.push(c);
259 }
260 }
261 }
262 }
263
264 containers
265}
266
267fn parse_container_block(block: &Block) -> Option<TerraformContainer> {
269 let mut name = String::new();
270 let mut image = None;
271 let mut requests = None;
272 let mut limits = None;
273
274 for structure in block.body().iter() {
275 match structure {
276 hcl::Structure::Attribute(attr) => match attr.key() {
277 "name" => {
278 name = expr_to_string(attr.expr()).unwrap_or_default();
279 }
280 "image" => {
281 image = expr_to_string(attr.expr());
282 }
283 _ => {}
284 },
285 hcl::Structure::Block(inner) => {
286 if inner.identifier() == "resources" {
287 (requests, limits) = parse_resources_block(inner);
288 }
289 }
290 }
291 }
292
293 if name.is_empty() {
294 return None;
295 }
296
297 Some(TerraformContainer {
298 name,
299 image,
300 requests,
301 limits,
302 })
303}
304
305fn parse_resources_block(block: &Block) -> (Option<TfResourceSpec>, Option<TfResourceSpec>) {
307 let mut requests = None;
308 let mut limits = None;
309
310 for structure in block.body().iter() {
311 if let hcl::Structure::Block(inner) = structure {
312 match inner.identifier() {
313 "requests" => {
314 requests = parse_resource_spec_block(inner);
315 }
316 "limits" => {
317 limits = parse_resource_spec_block(inner);
318 }
319 _ => {}
320 }
321 }
322 }
323
324 (requests, limits)
325}
326
327fn parse_resource_spec_block(block: &Block) -> Option<TfResourceSpec> {
329 let mut cpu = None;
330 let mut memory = None;
331
332 for structure in block.body().iter() {
333 if let hcl::Structure::Attribute(attr) = structure {
334 match attr.key() {
335 "cpu" => {
336 if let Some(cpu_str) = expr_to_string(attr.expr()) {
337 cpu = parse_cpu_to_millicores(&cpu_str);
338 }
339 }
340 "memory" => {
341 if let Some(mem_str) = expr_to_string(attr.expr()) {
342 memory = parse_memory_to_bytes(&mem_str);
343 }
344 }
345 _ => {}
346 }
347 }
348 }
349
350 if cpu.is_some() || memory.is_some() {
351 Some(TfResourceSpec { cpu, memory })
352 } else {
353 None
354 }
355}
356
357fn expr_to_string(expr: &hcl::Expression) -> Option<String> {
359 match expr {
360 hcl::Expression::String(s) => Some(s.clone()),
361 hcl::Expression::Number(n) => Some(n.to_string()),
362 hcl::Expression::TemplateExpr(t) => {
363 Some(format!("{}", t))
365 }
366 _ => None,
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use std::io::Write;
374
375 #[test]
376 #[ignore] fn test_parse_kubernetes_deployment() {
378 let tf_content = r#"
379resource "kubernetes_deployment" "nginx" {
380 metadata {
381 name = "nginx-deployment"
382 namespace = "default"
383 }
384
385 spec {
386 replicas = 3
387
388 template {
389 spec {
390 container {
391 name = "nginx"
392 image = "nginx:1.21"
393
394 resources {
395 requests {
396 cpu = "100m"
397 memory = "128Mi"
398 }
399 limits {
400 cpu = "500m"
401 memory = "512Mi"
402 }
403 }
404 }
405 }
406 }
407 }
408}
409"#;
410 let mut temp = tempfile::NamedTempFile::new().unwrap();
412 temp.write_all(tf_content.as_bytes()).unwrap();
413 let path = temp.path();
414
415 let resources = parse_terraform_k8s_resources(path);
416
417 assert_eq!(resources.len(), 1);
418 let res = &resources[0];
419 assert_eq!(res.resource_type, "kubernetes_deployment");
420 assert_eq!(res.tf_name, "nginx");
421 assert_eq!(res.k8s_name, Some("nginx-deployment".to_string()));
422 assert_eq!(res.namespace, Some("default".to_string()));
423 assert_eq!(res.containers.len(), 1);
424
425 let container = &res.containers[0];
426 assert_eq!(container.name, "nginx");
427 assert_eq!(container.image, Some("nginx:1.21".to_string()));
428
429 let requests = container.requests.as_ref().unwrap();
431 assert_eq!(requests.cpu, Some(100)); assert_eq!(requests.memory, Some(128 * 1024 * 1024)); let limits = container.limits.as_ref().unwrap();
436 assert_eq!(limits.cpu, Some(500)); assert_eq!(limits.memory, Some(512 * 1024 * 1024)); }
439
440 #[test]
441 #[ignore] fn test_parse_deployment_missing_resources() {
443 let tf_content = r#"
444resource "kubernetes_deployment_v1" "app" {
445 metadata {
446 name = "my-app"
447 }
448
449 spec {
450 template {
451 spec {
452 container {
453 name = "app"
454 image = "myapp:latest"
455 }
456 }
457 }
458 }
459}
460"#;
461 let mut temp = tempfile::NamedTempFile::new().unwrap();
462 temp.write_all(tf_content.as_bytes()).unwrap();
463
464 let resources = parse_terraform_k8s_resources(temp.path());
465
466 assert_eq!(resources.len(), 1);
467 let container = &resources[0].containers[0];
468 assert!(container.requests.is_none());
469 assert!(container.limits.is_none());
470 }
471
472 #[test]
473 #[ignore] fn test_ignores_non_k8s_resources() {
475 let tf_content = r#"
476resource "aws_instance" "example" {
477 ami = "ami-12345"
478 instance_type = "t2.micro"
479}
480
481resource "kubernetes_deployment" "app" {
482 metadata {
483 name = "my-app"
484 }
485 spec {
486 template {
487 spec {
488 container {
489 name = "app"
490 image = "myapp:latest"
491 }
492 }
493 }
494 }
495}
496"#;
497 let mut temp = tempfile::NamedTempFile::new().unwrap();
498 temp.write_all(tf_content.as_bytes()).unwrap();
499
500 let resources = parse_terraform_k8s_resources(temp.path());
501
502 assert_eq!(resources.len(), 1);
504 assert_eq!(resources[0].resource_type, "kubernetes_deployment");
505 }
506}