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 let scaling_pressure = &chart_keda["scalingPressure"];
261 check_str_num(
262 scaling_pressure,
263 "threshold",
264 u64::from(keda.scaling_pressure_threshold),
265 "keda.scalingPressure.threshold",
266 mismatches,
267 );
268
269 if let Some(enabled) = scaling_pressure["enabled"].as_bool()
270 && enabled != keda.scaling_pressure_enabled
271 {
272 mismatches.push(ContractMismatch {
273 field: "keda.scalingPressure.enabled".into(),
274 expected: keda.scaling_pressure_enabled.to_string(),
275 actual: enabled.to_string(),
276 });
277 }
278}
279
280fn validate_deployment_template(
281 template: &str,
282 contract: &DeploymentContract,
283 mismatches: &mut Vec<ContractMismatch>,
284) {
285 let liveness_pattern = format!("path: {}", contract.health.liveness_path);
287 if !template.contains(&liveness_pattern) {
288 mismatches.push(ContractMismatch {
289 field: "deployment liveness probe path".into(),
290 expected: contract.health.liveness_path.clone(),
291 actual: "(not found in template)".into(),
292 });
293 }
294
295 let readiness_pattern = format!("path: {}", contract.health.readiness_path);
296 if !template.contains(&readiness_pattern) {
297 mismatches.push(ContractMismatch {
298 field: "deployment readiness probe path".into(),
299 expected: contract.health.readiness_path.clone(),
300 actual: "(not found in template)".into(),
301 });
302 }
303
304 let env_pattern = format!("{}__", contract.env_prefix);
306 if !template.contains(&env_pattern) {
307 mismatches.push(ContractMismatch {
308 field: "deployment env var prefix".into(),
309 expected: env_pattern,
310 actual: "(not found in template)".into(),
311 });
312 }
313
314 if !template.contains(&contract.config_mount_path)
316 && !template.contains(
317 contract
318 .config_mount_path
319 .rsplit('/')
320 .nth(1)
321 .unwrap_or("/etc"),
322 )
323 {
324 mismatches.push(ContractMismatch {
325 field: "deployment config mount path".into(),
326 expected: contract.config_mount_path.clone(),
327 actual: "(not found in template)".into(),
328 });
329 }
330}
331
332fn check_u64(
334 parent: &serde_yaml_ng::Value,
335 key: &str,
336 expected: u64,
337 label: &str,
338 mismatches: &mut Vec<ContractMismatch>,
339) {
340 if let Some(val) = parent[key].as_u64()
341 && val != expected
342 {
343 mismatches.push(ContractMismatch {
344 field: label.into(),
345 expected: expected.to_string(),
346 actual: val.to_string(),
347 });
348 }
349}
350
351fn check_str_num(
353 parent: &serde_yaml_ng::Value,
354 key: &str,
355 expected: u64,
356 label: &str,
357 mismatches: &mut Vec<ContractMismatch>,
358) {
359 if let Some(val) = parent[key].as_str()
360 && val != expected.to_string()
361 {
362 mismatches.push(ContractMismatch {
363 field: label.into(),
364 expected: expected.to_string(),
365 actual: val.into(),
366 });
367 }
368}
369
370fn read_yaml(path: &Path) -> Result<serde_yaml_ng::Value, DeploymentError> {
371 if !path.exists() {
372 return Err(DeploymentError::NotFound(path.display().to_string()));
373 }
374 let content = std::fs::read_to_string(path).map_err(|e| DeploymentError::ReadFile {
375 path: path.display().to_string(),
376 source: e,
377 })?;
378 serde_yaml_ng::from_str(&content).map_err(|e| DeploymentError::ParseYaml {
379 path: path.display().to_string(),
380 source: e,
381 })
382}
383
384fn read_text(path: &Path) -> Result<String, DeploymentError> {
385 if !path.exists() {
386 return Err(DeploymentError::NotFound(path.display().to_string()));
387 }
388 std::fs::read_to_string(path).map_err(|e| DeploymentError::ReadFile {
389 path: path.display().to_string(),
390 source: e,
391 })
392}
393
394fn extract_line_containing(content: &str, keyword: &str) -> String {
395 content
396 .lines()
397 .find(|line| line.contains(keyword))
398 .unwrap_or("(not found)")
399 .trim()
400 .to_string()
401}
402
403#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::deployment::keda::KedaContract;
411
412 fn test_contract() -> DeploymentContract {
413 DeploymentContract {
414 app_name: "test-app".into(),
415 binary_name: "test-app".into(),
416 description: "Test application".into(),
417 metrics_port: 9090,
418 health: super::super::HealthContract::default(),
419 env_prefix: "TEST_APP".into(),
420 metric_prefix: "test".into(),
421 config_mount_path: "/etc/test/config.yaml".into(),
422 image_registry: "ghcr.io/hyperi-io".into(),
423 extra_ports: vec![],
424 entrypoint_args: vec!["--config".into(), "/etc/test/config.yaml".into()],
425 secrets: vec![],
426 default_config: None,
427 depends_on: vec![],
428 keda: Some(KedaContract::default()),
429 base_image: "ubuntu:24.04".into(),
430 native_deps: super::super::NativeDepsContract::default(),
431 image_profile: super::super::ImageProfile::default(),
432 schema_version: 2,
433 oci_labels: super::super::OciLabels::default(),
434 }
435 }
436
437 #[test]
438 fn test_validate_helm_not_found() {
439 let contract = test_contract();
440 let result = validate_helm_values(&contract, "/nonexistent/chart");
441 assert!(result.is_err());
442 }
443
444 #[test]
445 fn test_validate_dockerfile_not_found() {
446 let contract = test_contract();
447 let result = validate_dockerfile(&contract, "/nonexistent/Dockerfile");
448 assert!(result.is_err());
449 }
450
451 #[test]
452 fn test_validate_dockerfile_with_tempfile() {
453 let dir = tempfile::tempdir().unwrap();
454 let dockerfile = dir.path().join("Dockerfile");
455 std::fs::write(
456 &dockerfile,
457 "FROM ubuntu:24.04\n\
458 EXPOSE 9090\n\
459 HEALTHCHECK CMD curl -sf http://localhost:9090/healthz\n\
460 CMD [\"--config\", \"/etc/test/config.yaml\"]\n",
461 )
462 .unwrap();
463
464 let contract = test_contract();
465 let mismatches = validate_dockerfile(&contract, &dockerfile).unwrap();
466 assert!(
467 mismatches.is_empty(),
468 "Unexpected mismatches: {mismatches:?}"
469 );
470 }
471
472 #[test]
473 fn test_validate_dockerfile_wrong_port() {
474 let dir = tempfile::tempdir().unwrap();
475 let dockerfile = dir.path().join("Dockerfile");
476 std::fs::write(
477 &dockerfile,
478 "FROM ubuntu:24.04\n\
479 EXPOSE 8080\n\
480 HEALTHCHECK CMD curl -sf http://localhost:8080/healthz\n\
481 CMD [\"--config\", \"/etc/test/config.yaml\"]\n",
482 )
483 .unwrap();
484
485 let contract = test_contract();
486 let mismatches = validate_dockerfile(&contract, &dockerfile).unwrap();
487 assert!(!mismatches.is_empty());
488 assert!(mismatches.iter().any(|m| m.field.contains("EXPOSE")));
489 }
490
491 #[test]
492 fn test_validate_helm_with_tempdir() {
493 let dir = tempfile::tempdir().unwrap();
494 let chart_dir = dir.path();
495
496 std::fs::write(
498 chart_dir.join("Chart.yaml"),
499 "apiVersion: v2\nname: test-app\nversion: 0.1.0\n",
500 )
501 .unwrap();
502
503 std::fs::write(
505 chart_dir.join("values.yaml"),
506 "service:\n port: 9090\n\
507 config:\n metrics:\n address: \"0.0.0.0:9090\"\n\
508 podAnnotations:\n prometheus.io/port: \"9090\"\n prometheus.io/path: \"/metrics\"\n\
509 keda:\n minReplicaCount: 1\n maxReplicaCount: 10\n pollingInterval: 15\n cooldownPeriod: 300\n\
510 kafka:\n lagThreshold: \"1000\"\n activationLagThreshold: \"0\"\n\
511 cpu:\n enabled: true\n threshold: \"80\"\n\
512 scalingPressure:\n enabled: false\n threshold: \"70\"\n",
513 )
514 .unwrap();
515
516 std::fs::create_dir_all(chart_dir.join("templates")).unwrap();
518 std::fs::write(
519 chart_dir.join("templates/deployment.yaml"),
520 "path: /healthz\npath: /readyz\n\
521 TEST_APP__KAFKA__PASSWORD\n\
522 /etc/test/config.yaml\n",
523 )
524 .unwrap();
525
526 let contract = test_contract();
527 let mismatches = validate_helm_values(&contract, chart_dir).unwrap();
528 assert!(
529 mismatches.is_empty(),
530 "Unexpected mismatches: {mismatches:?}"
531 );
532 }
533
534 #[test]
535 fn test_validate_helm_wrong_port() {
536 let dir = tempfile::tempdir().unwrap();
537 let chart_dir = dir.path();
538
539 std::fs::write(
540 chart_dir.join("Chart.yaml"),
541 "apiVersion: v2\nname: test-app\nversion: 0.1.0\n",
542 )
543 .unwrap();
544 std::fs::write(chart_dir.join("values.yaml"), "service:\n port: 8080\n").unwrap();
545
546 let contract = test_contract();
547 let mismatches = validate_helm_values(&contract, chart_dir).unwrap();
548 assert!(mismatches.iter().any(|m| m.field == "service.port"));
549 }
550
551 #[test]
552 fn test_contract_mismatch_display() {
553 let m = ContractMismatch {
554 field: "service.port".into(),
555 expected: "9090".into(),
556 actual: "8080".into(),
557 };
558 assert_eq!(m.to_string(), "service.port: expected '9090', got '8080'");
559 }
560}