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
260fn validate_deployment_template(
261    template: &str,
262    contract: &DeploymentContract,
263    mismatches: &mut Vec<ContractMismatch>,
264) {
265    // Health probe paths
266    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    // Env var prefix (check for __ nesting pattern)
285    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    // Config mount path
295    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
312/// Check a YAML integer field against an expected value.
313fn 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
331/// Check a YAML string field that represents a number.
332fn 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// ============================================================================
384// Tests
385// ============================================================================
386
387#[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        // Chart.yaml
477        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        // values.yaml
484        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        // templates/deployment.yaml
496        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}