Skip to main content

hyperi_rustlib/deployment/
validate.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/validate.rs
3// Purpose:   Validate Helm charts and Dockerfiles against deployment contract
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Validate deployment artifacts against the app contract.
10//!
11//! [`validate_helm_values`] checks `chart/values.yaml` and template files.
12//! [`validate_dockerfile`] checks `Dockerfile` for port, healthcheck, and config path.
13
14use std::path::Path;
15
16use super::contract::DeploymentContract;
17use super::error::{ContractMismatch, DeploymentError};
18
19/// Validate a Helm chart directory against the deployment contract.
20///
21/// Checks `values.yaml` for port, prometheus annotations, KEDA thresholds,
22/// and the deployment template for health probe paths and env var prefix.
23///
24/// Returns a list of mismatches (empty = all good).
25///
26/// # Errors
27///
28/// Returns `DeploymentError` if chart files cannot be read or parsed.
29pub 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    // Parse values.yaml
37    let values_path = chart_dir.join("values.yaml");
38    let values = read_yaml(&values_path)?;
39
40    // Parse Chart.yaml
41    let chart_yaml_path = chart_dir.join("Chart.yaml");
42    let chart_yaml = read_yaml(&chart_yaml_path)?;
43
44    // Chart name
45    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    // Service port
56    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    // Metrics address
67    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    // Prometheus annotations
79    validate_prometheus_annotations(&values, contract, &mut mismatches);
80
81    // KEDA thresholds
82    if let Some(keda) = &contract.keda {
83        validate_keda_values(&values, keda, &mut mismatches);
84    }
85
86    // Deployment template (health probes, env prefix, config mount)
87    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
96/// Validate a Dockerfile against the deployment contract.
97///
98/// Checks EXPOSE port, HEALTHCHECK path, and config mount path.
99///
100/// Returns a list of mismatches (empty = all good).
101///
102/// # Errors
103///
104/// Returns `DeploymentError` if the Dockerfile cannot be read.
105pub 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    // EXPOSE port
114    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    // HEALTHCHECK path
124    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    // HEALTHCHECK port
133    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    // Config mount path
143    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
154// ============================================================================
155// Internal helpers
156// ============================================================================
157
158fn 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    // Kafka thresholds (strings in values.yaml)
223    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    // CPU threshold (string in values.yaml)
240    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    // Scaling-pressure trigger (Prometheus). Threshold is a string in values.yaml.
260    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    // Health probe paths
286    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    // Env var prefix (check for __ nesting pattern)
305    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    // Config mount path
315    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
332/// Check a YAML integer field against an expected value.
333fn 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
351/// Check a YAML string field that represents a number.
352fn 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// ============================================================================
404// Tests
405// ============================================================================
406
407#[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        // Chart.yaml
497        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        // values.yaml
504        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        // templates/deployment.yaml
517        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}