1use std::path::Path;
15
16use super::contract::DeploymentContract;
17use super::error::{ContractMismatch, DeploymentError};
18
19pub fn validate_helm_values(
30 contract: &DeploymentContract,
31 chart_dir: impl AsRef<Path>,
32) -> Result<Vec<ContractMismatch>, DeploymentError> {
33 let chart_dir = chart_dir.as_ref();
34 let mut mismatches = Vec::new();
35
36 let values_path = chart_dir.join("values.yaml");
38 let values = read_yaml(&values_path)?;
39
40 let chart_yaml_path = chart_dir.join("Chart.yaml");
42 let chart_yaml = read_yaml(&chart_yaml_path)?;
43
44 if let Some(name) = chart_yaml["name"].as_str()
46 && name != contract.app_name
47 {
48 mismatches.push(ContractMismatch {
49 field: "Chart.yaml name".into(),
50 expected: contract.app_name.clone(),
51 actual: name.into(),
52 });
53 }
54
55 if let Some(port) = values["service"]["port"].as_u64()
57 && port != u64::from(contract.metrics_port)
58 {
59 mismatches.push(ContractMismatch {
60 field: "service.port".into(),
61 expected: contract.metrics_port.to_string(),
62 actual: port.to_string(),
63 });
64 }
65
66 if let Some(addr) = values["config"]["metrics"]["address"].as_str() {
68 let expected_addr = format!("0.0.0.0:{}", contract.metrics_port);
69 if addr != expected_addr {
70 mismatches.push(ContractMismatch {
71 field: "config.metrics.address".into(),
72 expected: expected_addr,
73 actual: addr.into(),
74 });
75 }
76 }
77
78 validate_prometheus_annotations(&values, contract, &mut mismatches);
80
81 if let Some(keda) = &contract.keda {
83 validate_keda_values(&values, keda, &mut mismatches);
84 }
85
86 let deployment_path = chart_dir.join("templates/deployment.yaml");
88 if deployment_path.exists() {
89 let template = read_text(&deployment_path)?;
90 validate_deployment_template(&template, contract, &mut mismatches);
91 }
92
93 Ok(mismatches)
94}
95
96pub fn validate_dockerfile(
106 contract: &DeploymentContract,
107 dockerfile_path: impl AsRef<Path>,
108) -> Result<Vec<ContractMismatch>, DeploymentError> {
109 let dockerfile_path = dockerfile_path.as_ref();
110 let content = read_text(dockerfile_path)?;
111 let mut mismatches = Vec::new();
112
113 let expected_expose = format!("EXPOSE {}", contract.metrics_port);
115 if !content.contains(&expected_expose) {
116 mismatches.push(ContractMismatch {
117 field: "Dockerfile EXPOSE".into(),
118 expected: expected_expose,
119 actual: extract_line_containing(&content, "EXPOSE"),
120 });
121 }
122
123 if !content.contains(&contract.health.liveness_path) {
125 mismatches.push(ContractMismatch {
126 field: "Dockerfile HEALTHCHECK path".into(),
127 expected: contract.health.liveness_path.clone(),
128 actual: extract_line_containing(&content, "HEALTHCHECK"),
129 });
130 }
131
132 let port_str = format!("localhost:{}", contract.metrics_port);
134 if !content.contains(&port_str) {
135 mismatches.push(ContractMismatch {
136 field: "Dockerfile HEALTHCHECK port".into(),
137 expected: port_str,
138 actual: extract_line_containing(&content, "HEALTHCHECK"),
139 });
140 }
141
142 if !content.contains(&contract.config_mount_path) {
144 mismatches.push(ContractMismatch {
145 field: "Dockerfile config path".into(),
146 expected: contract.config_mount_path.clone(),
147 actual: extract_line_containing(&content, "CMD"),
148 });
149 }
150
151 Ok(mismatches)
152}
153
154fn validate_prometheus_annotations(
159 values: &serde_yaml_ng::Value,
160 contract: &DeploymentContract,
161 mismatches: &mut Vec<ContractMismatch>,
162) {
163 let annotations = &values["podAnnotations"];
164
165 if let Some(port) = annotations["prometheus.io/port"].as_str()
166 && port != contract.metrics_port.to_string()
167 {
168 mismatches.push(ContractMismatch {
169 field: "podAnnotations prometheus.io/port".into(),
170 expected: contract.metrics_port.to_string(),
171 actual: port.into(),
172 });
173 }
174
175 if let Some(path) = annotations["prometheus.io/path"].as_str()
176 && path != contract.health.metrics_path
177 {
178 mismatches.push(ContractMismatch {
179 field: "podAnnotations prometheus.io/path".into(),
180 expected: contract.health.metrics_path.clone(),
181 actual: path.into(),
182 });
183 }
184}
185
186fn validate_keda_values(
187 values: &serde_yaml_ng::Value,
188 keda: &super::keda::KedaContract,
189 mismatches: &mut Vec<ContractMismatch>,
190) {
191 let chart_keda = &values["keda"];
192
193 check_u64(
194 chart_keda,
195 "minReplicaCount",
196 u64::from(keda.min_replicas),
197 "keda.minReplicaCount",
198 mismatches,
199 );
200 check_u64(
201 chart_keda,
202 "maxReplicaCount",
203 u64::from(keda.max_replicas),
204 "keda.maxReplicaCount",
205 mismatches,
206 );
207 check_u64(
208 chart_keda,
209 "pollingInterval",
210 u64::from(keda.polling_interval),
211 "keda.pollingInterval",
212 mismatches,
213 );
214 check_u64(
215 chart_keda,
216 "cooldownPeriod",
217 u64::from(keda.cooldown_period),
218 "keda.cooldownPeriod",
219 mismatches,
220 );
221
222 let kafka = &chart_keda["kafka"];
224 check_str_num(
225 kafka,
226 "lagThreshold",
227 keda.kafka_lag_threshold,
228 "keda.kafka.lagThreshold",
229 mismatches,
230 );
231 check_str_num(
232 kafka,
233 "activationLagThreshold",
234 keda.activation_lag_threshold,
235 "keda.kafka.activationLagThreshold",
236 mismatches,
237 );
238
239 let cpu = &chart_keda["cpu"];
241 check_str_num(
242 cpu,
243 "threshold",
244 u64::from(keda.cpu_threshold),
245 "keda.cpu.threshold",
246 mismatches,
247 );
248
249 if let Some(enabled) = cpu["enabled"].as_bool()
250 && enabled != keda.cpu_enabled
251 {
252 mismatches.push(ContractMismatch {
253 field: "keda.cpu.enabled".into(),
254 expected: keda.cpu_enabled.to_string(),
255 actual: enabled.to_string(),
256 });
257 }
258}
259
260fn validate_deployment_template(
261 template: &str,
262 contract: &DeploymentContract,
263 mismatches: &mut Vec<ContractMismatch>,
264) {
265 let liveness_pattern = format!("path: {}", contract.health.liveness_path);
267 if !template.contains(&liveness_pattern) {
268 mismatches.push(ContractMismatch {
269 field: "deployment liveness probe path".into(),
270 expected: contract.health.liveness_path.clone(),
271 actual: "(not found in template)".into(),
272 });
273 }
274
275 let readiness_pattern = format!("path: {}", contract.health.readiness_path);
276 if !template.contains(&readiness_pattern) {
277 mismatches.push(ContractMismatch {
278 field: "deployment readiness probe path".into(),
279 expected: contract.health.readiness_path.clone(),
280 actual: "(not found in template)".into(),
281 });
282 }
283
284 let env_pattern = format!("{}__", contract.env_prefix);
286 if !template.contains(&env_pattern) {
287 mismatches.push(ContractMismatch {
288 field: "deployment env var prefix".into(),
289 expected: env_pattern,
290 actual: "(not found in template)".into(),
291 });
292 }
293
294 if !template.contains(&contract.config_mount_path)
296 && !template.contains(
297 contract
298 .config_mount_path
299 .rsplit('/')
300 .nth(1)
301 .unwrap_or("/etc"),
302 )
303 {
304 mismatches.push(ContractMismatch {
305 field: "deployment config mount path".into(),
306 expected: contract.config_mount_path.clone(),
307 actual: "(not found in template)".into(),
308 });
309 }
310}
311
312fn check_u64(
314 parent: &serde_yaml_ng::Value,
315 key: &str,
316 expected: u64,
317 label: &str,
318 mismatches: &mut Vec<ContractMismatch>,
319) {
320 if let Some(val) = parent[key].as_u64()
321 && val != expected
322 {
323 mismatches.push(ContractMismatch {
324 field: label.into(),
325 expected: expected.to_string(),
326 actual: val.to_string(),
327 });
328 }
329}
330
331fn check_str_num(
333 parent: &serde_yaml_ng::Value,
334 key: &str,
335 expected: u64,
336 label: &str,
337 mismatches: &mut Vec<ContractMismatch>,
338) {
339 if let Some(val) = parent[key].as_str()
340 && val != expected.to_string()
341 {
342 mismatches.push(ContractMismatch {
343 field: label.into(),
344 expected: expected.to_string(),
345 actual: val.into(),
346 });
347 }
348}
349
350fn read_yaml(path: &Path) -> Result<serde_yaml_ng::Value, DeploymentError> {
351 if !path.exists() {
352 return Err(DeploymentError::NotFound(path.display().to_string()));
353 }
354 let content = std::fs::read_to_string(path).map_err(|e| DeploymentError::ReadFile {
355 path: path.display().to_string(),
356 source: e,
357 })?;
358 serde_yaml_ng::from_str(&content).map_err(|e| DeploymentError::ParseYaml {
359 path: path.display().to_string(),
360 source: e,
361 })
362}
363
364fn read_text(path: &Path) -> Result<String, DeploymentError> {
365 if !path.exists() {
366 return Err(DeploymentError::NotFound(path.display().to_string()));
367 }
368 std::fs::read_to_string(path).map_err(|e| DeploymentError::ReadFile {
369 path: path.display().to_string(),
370 source: e,
371 })
372}
373
374fn extract_line_containing(content: &str, keyword: &str) -> String {
375 content
376 .lines()
377 .find(|line| line.contains(keyword))
378 .unwrap_or("(not found)")
379 .trim()
380 .to_string()
381}
382
383#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::deployment::keda::KedaContract;
391
392 fn test_contract() -> DeploymentContract {
393 DeploymentContract {
394 app_name: "test-app".into(),
395 binary_name: "test-app".into(),
396 description: "Test application".into(),
397 metrics_port: 9090,
398 health: super::super::HealthContract::default(),
399 env_prefix: "TEST_APP".into(),
400 metric_prefix: "test".into(),
401 config_mount_path: "/etc/test/config.yaml".into(),
402 image_registry: "ghcr.io/hyperi-io".into(),
403 extra_ports: vec![],
404 entrypoint_args: vec!["--config".into(), "/etc/test/config.yaml".into()],
405 secrets: vec![],
406 default_config: None,
407 depends_on: vec![],
408 keda: Some(KedaContract::default()),
409 base_image: "ubuntu:24.04".into(),
410 native_deps: super::super::NativeDepsContract::default(),
411 image_profile: super::super::ImageProfile::default(),
412 schema_version: 2,
413 oci_labels: super::super::OciLabels::default(),
414 }
415 }
416
417 #[test]
418 fn test_validate_helm_not_found() {
419 let contract = test_contract();
420 let result = validate_helm_values(&contract, "/nonexistent/chart");
421 assert!(result.is_err());
422 }
423
424 #[test]
425 fn test_validate_dockerfile_not_found() {
426 let contract = test_contract();
427 let result = validate_dockerfile(&contract, "/nonexistent/Dockerfile");
428 assert!(result.is_err());
429 }
430
431 #[test]
432 fn test_validate_dockerfile_with_tempfile() {
433 let dir = tempfile::tempdir().unwrap();
434 let dockerfile = dir.path().join("Dockerfile");
435 std::fs::write(
436 &dockerfile,
437 "FROM ubuntu:24.04\n\
438 EXPOSE 9090\n\
439 HEALTHCHECK CMD curl -sf http://localhost:9090/healthz\n\
440 CMD [\"--config\", \"/etc/test/config.yaml\"]\n",
441 )
442 .unwrap();
443
444 let contract = test_contract();
445 let mismatches = validate_dockerfile(&contract, &dockerfile).unwrap();
446 assert!(
447 mismatches.is_empty(),
448 "Unexpected mismatches: {mismatches:?}"
449 );
450 }
451
452 #[test]
453 fn test_validate_dockerfile_wrong_port() {
454 let dir = tempfile::tempdir().unwrap();
455 let dockerfile = dir.path().join("Dockerfile");
456 std::fs::write(
457 &dockerfile,
458 "FROM ubuntu:24.04\n\
459 EXPOSE 8080\n\
460 HEALTHCHECK CMD curl -sf http://localhost:8080/healthz\n\
461 CMD [\"--config\", \"/etc/test/config.yaml\"]\n",
462 )
463 .unwrap();
464
465 let contract = test_contract();
466 let mismatches = validate_dockerfile(&contract, &dockerfile).unwrap();
467 assert!(!mismatches.is_empty());
468 assert!(mismatches.iter().any(|m| m.field.contains("EXPOSE")));
469 }
470
471 #[test]
472 fn test_validate_helm_with_tempdir() {
473 let dir = tempfile::tempdir().unwrap();
474 let chart_dir = dir.path();
475
476 std::fs::write(
478 chart_dir.join("Chart.yaml"),
479 "apiVersion: v2\nname: test-app\nversion: 0.1.0\n",
480 )
481 .unwrap();
482
483 std::fs::write(
485 chart_dir.join("values.yaml"),
486 "service:\n port: 9090\n\
487 config:\n metrics:\n address: \"0.0.0.0:9090\"\n\
488 podAnnotations:\n prometheus.io/port: \"9090\"\n prometheus.io/path: \"/metrics\"\n\
489 keda:\n minReplicaCount: 1\n maxReplicaCount: 10\n pollingInterval: 15\n cooldownPeriod: 300\n\
490 kafka:\n lagThreshold: \"1000\"\n activationLagThreshold: \"0\"\n\
491 cpu:\n enabled: true\n threshold: \"80\"\n",
492 )
493 .unwrap();
494
495 std::fs::create_dir_all(chart_dir.join("templates")).unwrap();
497 std::fs::write(
498 chart_dir.join("templates/deployment.yaml"),
499 "path: /healthz\npath: /readyz\n\
500 TEST_APP__KAFKA__PASSWORD\n\
501 /etc/test/config.yaml\n",
502 )
503 .unwrap();
504
505 let contract = test_contract();
506 let mismatches = validate_helm_values(&contract, chart_dir).unwrap();
507 assert!(
508 mismatches.is_empty(),
509 "Unexpected mismatches: {mismatches:?}"
510 );
511 }
512
513 #[test]
514 fn test_validate_helm_wrong_port() {
515 let dir = tempfile::tempdir().unwrap();
516 let chart_dir = dir.path();
517
518 std::fs::write(
519 chart_dir.join("Chart.yaml"),
520 "apiVersion: v2\nname: test-app\nversion: 0.1.0\n",
521 )
522 .unwrap();
523 std::fs::write(chart_dir.join("values.yaml"), "service:\n port: 8080\n").unwrap();
524
525 let contract = test_contract();
526 let mismatches = validate_helm_values(&contract, chart_dir).unwrap();
527 assert!(mismatches.iter().any(|m| m.field == "service.port"));
528 }
529
530 #[test]
531 fn test_contract_mismatch_display() {
532 let m = ContractMismatch {
533 field: "service.port".into(),
534 expected: "9090".into(),
535 actual: "8080".into(),
536 };
537 assert_eq!(m.to_string(), "service.port: expected '9090', got '8080'");
538 }
539}