Skip to main content

hyperi_rustlib/deployment/generate/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/generate/mod.rs
3// Purpose:   Generate deployment artifacts from DeploymentContract
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Generate deployment artifacts (Dockerfile, Helm chart, Compose fragment,
10//! container manifest, ArgoCD Application) from a
11//! [`DeploymentContract`](crate::deployment::DeploymentContract).
12//!
13//! Apps provide ~20% customisation (ports, secrets, config); this module
14//! generates ~80% boilerplate. Split by artefact kind into submodules;
15//! the public surface is unchanged (re-exported here).
16
17mod argocd;
18mod common;
19mod compose;
20mod dockerfile;
21mod helm;
22mod manifest;
23
24pub use argocd::{ArgocdConfig, generate_argocd_application};
25pub use compose::generate_compose_fragment;
26pub use dockerfile::{generate_dockerfile, generate_runtime_stage};
27pub use helm::generate_chart;
28pub use manifest::generate_container_manifest;
29
30// Tests call these private helpers + contract types directly via `use super::*`.
31#[cfg(test)]
32use crate::deployment::contract::{DeploymentContract, ImageProfile};
33#[cfg(test)]
34use common::{is_go_identifier, safe_template_lookup, to_camel_suffix};
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39    use crate::deployment::contract::{
40        OciLabels, PortContract, SecretEnvContract, SecretGroupContract,
41    };
42    use crate::deployment::keda::KedaContract;
43    use crate::deployment::native_deps::NativeDepsContract;
44
45    fn test_contract() -> DeploymentContract {
46        DeploymentContract {
47            app_name: "dfe-loader".into(),
48            binary_name: "dfe-loader".into(),
49            description: "High-performance Kafka to ClickHouse data loader".into(),
50            metrics_port: 9090,
51            health: super::super::HealthContract::default(),
52            env_prefix: "DFE_LOADER".into(),
53            metric_prefix: "loader".into(),
54            config_mount_path: "/etc/dfe/loader.yaml".into(),
55            image_registry: "ghcr.io/hyperi-io".into(),
56            extra_ports: vec![],
57            entrypoint_args: vec!["--config".into(), "/etc/dfe/loader.yaml".into()],
58            secrets: vec![
59                SecretGroupContract {
60                    group_name: "kafka".into(),
61                    env_vars: vec![
62                        SecretEnvContract {
63                            env_var: "DFE_LOADER__KAFKA__USERNAME".into(),
64                            key_name: "username".into(),
65                            secret_key: "kafka-username".into(),
66                        },
67                        SecretEnvContract {
68                            env_var: "DFE_LOADER__KAFKA__PASSWORD".into(),
69                            key_name: "password".into(),
70                            secret_key: "kafka-password".into(),
71                        },
72                    ],
73                },
74                SecretGroupContract {
75                    group_name: "clickhouse".into(),
76                    env_vars: vec![SecretEnvContract {
77                        env_var: "DFE_LOADER__CLICKHOUSE__PASSWORD".into(),
78                        key_name: "password".into(),
79                        secret_key: "clickhouse-password".into(),
80                    }],
81                },
82            ],
83            default_config: None,
84            depends_on: vec!["kafka".into(), "clickhouse".into()],
85            keda: Some(KedaContract::default()),
86            base_image: "ubuntu:24.04".into(),
87            native_deps: NativeDepsContract::default(),
88            image_profile: ImageProfile::default(),
89            schema_version: 2,
90            oci_labels: OciLabels::default(),
91        }
92    }
93
94    #[test]
95    fn test_generate_dockerfile() {
96        let contract = test_contract();
97        let dockerfile = generate_dockerfile(&contract, None);
98
99        assert!(dockerfile.contains("FROM ubuntu:24.04"));
100        assert!(dockerfile.contains("COPY dfe-loader /usr/local/bin/dfe-loader"));
101        assert!(dockerfile.contains("EXPOSE 9090"));
102        assert!(dockerfile.contains("localhost:9090/healthz"));
103        assert!(dockerfile.contains("ENTRYPOINT [\"dfe-loader\"]"));
104        assert!(dockerfile.contains("CMD [\"--config\", \"/etc/dfe/loader.yaml\"]"));
105    }
106
107    #[test]
108    fn test_generate_dockerfile_with_native_deps() {
109        let mut contract = test_contract();
110        contract.native_deps = NativeDepsContract::for_rustlib_features(
111            &["transport-kafka", "spool", "tiered-sink"],
112            "ubuntu:24.04",
113        );
114
115        let dockerfile = generate_dockerfile(&contract, None);
116
117        // Should contain Confluent APT repo setup
118        assert!(dockerfile.contains("packages.confluent.io"));
119        assert!(dockerfile.contains("confluent-clients.gpg"));
120        // Should contain runtime packages
121        assert!(dockerfile.contains("librdkafka1"));
122        assert!(dockerfile.contains("libssl3"));
123        assert!(dockerfile.contains("libzstd1"));
124        // Should include gnupg for key import
125        assert!(dockerfile.contains("gnupg"));
126    }
127
128    #[test]
129    fn test_generate_dockerfile_no_native_deps() {
130        let mut contract = test_contract();
131        contract.native_deps = NativeDepsContract::for_rustlib_features(
132            &["cli", "deployment", "logger"],
133            "ubuntu:24.04",
134        );
135
136        let dockerfile = generate_dockerfile(&contract, None);
137
138        // No Confluent repo, no runtime packages
139        assert!(!dockerfile.contains("confluent"));
140        assert!(!dockerfile.contains("librdkafka1"));
141        assert!(!dockerfile.contains("gnupg"));
142    }
143
144    #[test]
145    fn test_generate_dockerfile_bookworm_codename() {
146        let mut contract = test_contract();
147        contract.base_image = "debian:bookworm-slim".into();
148        contract.native_deps =
149            NativeDepsContract::for_rustlib_features(&["transport-kafka"], "debian:bookworm-slim");
150
151        let dockerfile = generate_dockerfile(&contract, None);
152        assert!(dockerfile.contains("bookworm main"));
153    }
154
155    #[test]
156    fn test_generate_dockerfile_production_profile() {
157        let contract = test_contract();
158        let dockerfile = generate_dockerfile(&contract, None);
159
160        assert!(dockerfile.contains("Purpose:   production container image"));
161        assert!(dockerfile.contains("io.hyperi.profile=\"production\""));
162        assert!(!dockerfile.contains("strace"));
163        assert!(!dockerfile.contains("tcpdump"));
164    }
165
166    #[test]
167    fn test_generate_dockerfile_dev_profile() {
168        let contract = test_contract().with_dev_profile();
169        let dockerfile = generate_dockerfile(&contract, None);
170
171        assert!(dockerfile.contains("Purpose:   development container image"));
172        assert!(dockerfile.contains("io.hyperi.profile=\"development\""));
173        assert!(dockerfile.contains("strace"));
174        assert!(dockerfile.contains("tcpdump"));
175        assert!(dockerfile.contains("procps"));
176        assert!(dockerfile.contains("bash"));
177        assert!(dockerfile.contains("jq"));
178    }
179
180    #[test]
181    fn test_generate_dockerfile_dev_with_native_deps() {
182        let mut contract = test_contract();
183        contract.native_deps =
184            NativeDepsContract::for_rustlib_features(&["transport-kafka", "spool"], "ubuntu:24.04");
185        let dev = contract.with_dev_profile();
186        let dockerfile = generate_dockerfile(&dev, None);
187
188        // Dev tools present alongside native deps
189        assert!(dockerfile.contains("strace"));
190        assert!(dockerfile.contains("librdkafka1"));
191        assert!(dockerfile.contains("libzstd1"));
192        assert!(dockerfile.contains("io.hyperi.profile=\"development\""));
193    }
194
195    #[test]
196    fn test_with_dev_profile_preserves_contract() {
197        let contract = test_contract();
198        let dev = contract.with_dev_profile();
199
200        assert_eq!(dev.app_name, contract.app_name);
201        assert_eq!(dev.metrics_port, contract.metrics_port);
202        assert_eq!(dev.image_profile, ImageProfile::Development);
203        assert_eq!(contract.image_profile, ImageProfile::Production);
204    }
205
206    #[test]
207    fn test_generate_dockerfile_extra_ports() {
208        let mut contract = test_contract();
209        contract.extra_ports = vec![PortContract {
210            name: "http".into(),
211            port: 8080,
212            protocol: "TCP".into(),
213        }];
214
215        let dockerfile = generate_dockerfile(&contract, None);
216        assert!(dockerfile.contains("EXPOSE 9090 8080"));
217    }
218
219    #[test]
220    fn test_generate_compose_fragment() {
221        let contract = test_contract();
222        let compose = generate_compose_fragment(&contract);
223
224        assert!(compose.contains("dfe-loader:"));
225        assert!(compose.contains("ghcr.io/hyperi-io/dfe-loader"));
226        assert!(compose.contains("kafka:"));
227        assert!(compose.contains("clickhouse:"));
228        assert!(compose.contains("condition: service_healthy"));
229        assert!(compose.contains("\"9090:9090\""));
230        assert!(compose.contains("loader.yaml:/etc/dfe/loader.yaml:ro"));
231    }
232
233    #[test]
234    fn test_generate_chart() {
235        let contract = test_contract();
236        let dir = tempfile::tempdir().unwrap();
237
238        generate_chart(&contract, dir.path(), None).unwrap();
239
240        // Verify files exist
241        assert!(dir.path().join("Chart.yaml").exists());
242        assert!(dir.path().join("values.yaml").exists());
243        assert!(dir.path().join("templates/_helpers.tpl").exists());
244        assert!(dir.path().join("templates/deployment.yaml").exists());
245        assert!(dir.path().join("templates/service.yaml").exists());
246        assert!(dir.path().join("templates/serviceaccount.yaml").exists());
247        assert!(dir.path().join("templates/configmap.yaml").exists());
248        assert!(dir.path().join("templates/secret.yaml").exists());
249        assert!(dir.path().join("templates/hpa.yaml").exists());
250        assert!(dir.path().join("templates/keda-scaledobject.yaml").exists());
251        assert!(dir.path().join("templates/keda-triggerauth.yaml").exists());
252        assert!(dir.path().join("templates/NOTES.txt").exists());
253    }
254
255    #[test]
256    fn test_chart_yaml_content() {
257        let contract = test_contract();
258        let dir = tempfile::tempdir().unwrap();
259        generate_chart(&contract, dir.path(), None).unwrap();
260
261        let content = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
262        assert!(content.contains("name: dfe-loader"));
263        assert!(content.contains("description: High-performance Kafka to ClickHouse data loader"));
264    }
265
266    #[test]
267    fn test_values_yaml_content() {
268        let contract = test_contract();
269        let dir = tempfile::tempdir().unwrap();
270        generate_chart(&contract, dir.path(), None).unwrap();
271
272        let content = std::fs::read_to_string(dir.path().join("values.yaml")).unwrap();
273        assert!(content.contains("port: 9090"));
274        assert!(content.contains("prometheus.io/port: \"9090\""));
275        assert!(content.contains("prometheus.io/path: \"/metrics\""));
276        assert!(content.contains("lagThreshold: \"1000\""));
277        assert!(content.contains("kafka-username"));
278        assert!(content.contains("kafka-password"));
279        assert!(content.contains("clickhouse-password"));
280    }
281
282    #[test]
283    fn test_values_yaml_has_scaling_pressure_block() {
284        let contract = test_contract();
285        let dir = tempfile::tempdir().unwrap();
286        generate_chart(&contract, dir.path(), None).unwrap();
287
288        let content = std::fs::read_to_string(dir.path().join("values.yaml")).unwrap();
289        // Opt-in (default off), with the per-pod-sum query derived from
290        // metric_prefix and the threshold seeded from the contract.
291        assert!(content.contains("scalingPressure:"));
292        assert!(content.contains("query: \"avg(loader_scaling_pressure)\""));
293        assert!(content.contains("threshold: \"70\""));
294    }
295
296    #[test]
297    fn test_keda_scaledobject_has_scaling_pressure_trigger() {
298        let contract = test_contract();
299        let dir = tempfile::tempdir().unwrap();
300        generate_chart(&contract, dir.path(), None).unwrap();
301
302        let keda_yaml =
303            std::fs::read_to_string(dir.path().join("templates/keda-scaledobject.yaml")).unwrap();
304        // Runtime-gated Prometheus trigger on the correlated-composite gauge.
305        assert!(
306            keda_yaml.contains("if .Values.keda.scalingPressure.enabled"),
307            "scaledobject missing scalingPressure guard:\n{keda_yaml}"
308        );
309        assert!(keda_yaml.contains("type: prometheus"));
310        assert!(keda_yaml.contains(".Values.keda.scalingPressure.query"));
311        assert!(
312            keda_yaml.contains("metricType: Value"),
313            "capped per-pod composite uses avg()+Value (per KEDA.md):\n{keda_yaml}"
314        );
315    }
316
317    #[test]
318    fn test_helpers_contain_secret_helpers() {
319        let contract = test_contract();
320        let dir = tempfile::tempdir().unwrap();
321        generate_chart(&contract, dir.path(), None).unwrap();
322
323        let content = std::fs::read_to_string(dir.path().join("templates/_helpers.tpl")).unwrap();
324        assert!(content.contains("kafkaSecretName"));
325        assert!(content.contains("clickhouseSecretName"));
326    }
327
328    #[test]
329    fn test_deployment_contains_env_vars() {
330        let contract = test_contract();
331        let dir = tempfile::tempdir().unwrap();
332        generate_chart(&contract, dir.path(), None).unwrap();
333
334        let content =
335            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
336        assert!(content.contains("DFE_LOADER__KAFKA__USERNAME"));
337        assert!(content.contains("DFE_LOADER__KAFKA__PASSWORD"));
338        assert!(content.contains("DFE_LOADER__CLICKHOUSE__PASSWORD"));
339        assert!(content.contains("path: /healthz"));
340        assert!(content.contains("path: /readyz"));
341        assert!(content.contains("/etc/dfe"));
342    }
343
344    #[test]
345    fn test_is_go_identifier() {
346        // Valid Go identifiers
347        assert!(is_go_identifier("foo"));
348        assert!(is_go_identifier("FOO"));
349        assert!(is_go_identifier("foo_bar"));
350        assert!(is_go_identifier("_underscore_start"));
351        assert!(is_go_identifier("foo123"));
352        assert!(is_go_identifier("a"));
353
354        // Invalid -- would break Go templates
355        assert!(!is_go_identifier("bearer-tokens")); // hyphen
356        assert!(!is_go_identifier("foo.bar")); // dot
357        assert!(!is_go_identifier("123foo")); // digit-leading
358        assert!(!is_go_identifier("")); // empty
359        assert!(!is_go_identifier("foo bar")); // space
360        assert!(!is_go_identifier("foo:bar")); // colon
361    }
362
363    #[test]
364    fn test_safe_template_lookup_chooses_form() {
365        assert_eq!(
366            safe_template_lookup(".Values.auth", "username"),
367            ".Values.auth.username"
368        );
369        assert_eq!(
370            safe_template_lookup(".Values.auth", "bearer-tokens"),
371            "(index .Values.auth \"bearer-tokens\")"
372        );
373        assert_eq!(
374            safe_template_lookup(".Values.kafka.secretKeys", "kafka-username"),
375            "(index .Values.kafka.secretKeys \"kafka-username\")"
376        );
377    }
378
379    /// Regression for the dfe-receiver canary 2026-05-25 finding:
380    /// keda-scaledobject.yaml previously used
381    /// `default (index .Values.config.kafka.topics 0)` which `helm lint`
382    /// rejects with `error calling index: index of untyped nil` because
383    /// Sprig's `default` evaluates both operands. The render must now
384    /// use a conditional `if/else if/else` block instead.
385    #[test]
386    fn test_keda_scaledobject_topic_lookup_is_lint_safe() {
387        let contract = test_contract();
388        let dir = tempfile::tempdir().unwrap();
389        generate_chart(&contract, dir.path(), None).unwrap();
390
391        let keda_yaml =
392            std::fs::read_to_string(dir.path().join("templates/keda-scaledobject.yaml")).unwrap();
393
394        // Old broken form must not appear
395        assert!(
396            !keda_yaml.contains(
397                ".Values.keda.kafka.topic | default (index .Values.config.kafka.topics 0)"
398            ),
399            "keda-scaledobject.yaml still uses the eagerly-evaluated `default (index ...)` form:\n{keda_yaml}"
400        );
401
402        // New conditional form must appear
403        assert!(
404            keda_yaml.contains("if .Values.keda.kafka.topic"),
405            "keda-scaledobject.yaml missing if/else guard for topic lookup:\n{keda_yaml}"
406        );
407        assert!(
408            keda_yaml.contains("else if .Values.config.kafka.topics"),
409            "keda-scaledobject.yaml missing fallback branch for config.kafka.topics:\n{keda_yaml}"
410        );
411    }
412
413    /// Regression for the dfe-receiver canary 2026-05-25 finding:
414    /// secret.yaml previously emitted `.Values.x.bearer-tokens` which
415    /// Go templates reject ("bad character U+002D '-'"). The render
416    /// must now use the `(index .Values.x "bearer-tokens")` form.
417    #[test]
418    fn test_secret_yaml_handles_hyphenated_key_names() {
419        let mut contract = test_contract();
420        // dfe-receiver-style hyphenated key_name (token group)
421        contract.secrets.push(SecretGroupContract {
422            group_name: "auth".into(),
423            env_vars: vec![SecretEnvContract {
424                env_var: "DFE_RECEIVER__AUTH__BEARER_TOKENS".into(),
425                key_name: "bearer-tokens".into(),
426                secret_key: "bearer-tokens".into(),
427            }],
428        });
429
430        let dir = tempfile::tempdir().unwrap();
431        generate_chart(&contract, dir.path(), None).unwrap();
432
433        let secret_yaml =
434            std::fs::read_to_string(dir.path().join("templates/secret.yaml")).unwrap();
435        let deployment_yaml =
436            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
437
438        // Old broken form must not appear anywhere
439        assert!(
440            !secret_yaml.contains(".Values.auth.bearer-tokens"),
441            "secret.yaml still uses broken dot-walked form for hyphenated key:\n{secret_yaml}"
442        );
443        assert!(
444            !secret_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
445            "secret.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{secret_yaml}"
446        );
447        assert!(
448            !deployment_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
449            "deployment.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{deployment_yaml}"
450        );
451
452        // Safe index form must appear
453        assert!(
454            secret_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
455            "secret.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{secret_yaml}"
456        );
457        assert!(
458            secret_yaml.contains("(index .Values.auth \"bearer-tokens\")"),
459            "secret.yaml missing index-form lookup for value bearer-tokens:\n{secret_yaml}"
460        );
461        assert!(
462            deployment_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
463            "deployment.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{deployment_yaml}"
464        );
465
466        // Sanity: Go-safe keys (e.g. existing kafka.username) still use dot form
467        assert!(
468            secret_yaml.contains(".Values.kafka.secretKeys.username"),
469            "Go-safe key 'username' should still use dot-walked form:\n{secret_yaml}"
470        );
471    }
472
473    #[test]
474    fn test_generate_argocd_application_default() {
475        let contract = test_contract();
476        let argo = ArgocdConfig {
477            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
478            ..Default::default()
479        };
480        let yaml = generate_argocd_application(&contract, &argo, None);
481
482        assert!(yaml.contains("apiVersion: argoproj.io/v1alpha1"));
483        assert!(yaml.contains("kind: Application"));
484        assert!(yaml.contains("name: dfe-loader"));
485        assert!(yaml.contains("namespace: argocd"));
486        assert!(yaml.contains("repoURL: https://github.com/hyperi-io/dfe-loader"));
487        assert!(yaml.contains("targetRevision: main"));
488        assert!(yaml.contains("path: chart"));
489        assert!(yaml.contains("CreateNamespace=true"));
490        assert!(yaml.contains("Schema version: "));
491    }
492
493    #[test]
494    fn test_generate_argocd_custom_namespace_and_path() {
495        let contract = test_contract();
496        let argo = ArgocdConfig {
497            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
498            dest_namespace: "production".into(),
499            chart_path: "deploy/chart".into(),
500            target_revision: "v1.0.0".into(),
501            sync_wave: 5,
502            ..Default::default()
503        };
504        let yaml = generate_argocd_application(&contract, &argo, None);
505        assert!(yaml.contains("namespace: production"));
506        assert!(yaml.contains("path: deploy/chart"));
507        assert!(yaml.contains("targetRevision: v1.0.0"));
508        assert!(yaml.contains("sync-wave: \"5\""));
509    }
510
511    #[test]
512    fn argocd_config_default_uses_wave_apps() {
513        let cfg = ArgocdConfig::default();
514        assert_eq!(cfg.sync_wave, crate::deployment::WAVE_APPS);
515    }
516
517    #[test]
518    fn argocd_config_default_has_no_extra_ignore_differences() {
519        let cfg = ArgocdConfig::default();
520        assert!(cfg.extra_ignore_differences.is_empty());
521    }
522
523    #[test]
524    fn generate_argocd_application_emits_default_ignore_differences() {
525        let contract = test_contract();
526        let argo = ArgocdConfig {
527            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
528            ..Default::default()
529        };
530        let yaml = generate_argocd_application(&contract, &argo, None);
531        assert!(yaml.contains("ignoreDifferences:"));
532        assert!(yaml.contains("/spec/replicas"));
533        assert!(yaml.contains("/spec/clusterIP"));
534        assert!(yaml.contains(".webhooks[].clientConfig.caBundle"));
535    }
536
537    #[test]
538    fn generate_argocd_application_appends_extra_ignore_differences() {
539        let contract = test_contract();
540        let argo = ArgocdConfig {
541            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
542            extra_ignore_differences: vec![
543                "- group: apps\n  kind: Deployment\n  jsonPointers:\n    - /spec/template/spec/containers/0/image".into(),
544            ],
545            ..Default::default()
546        };
547        let yaml = generate_argocd_application(&contract, &argo, None);
548        assert!(yaml.contains("/spec/template/spec/containers/0/image"));
549    }
550
551    #[test]
552    fn generate_argocd_application_sync_wave_annotation_uses_config_value() {
553        let contract = test_contract();
554        let argo = ArgocdConfig {
555            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
556            sync_wave: crate::deployment::WAVE_TOPICS,
557            ..Default::default()
558        };
559        let yaml = generate_argocd_application(&contract, &argo, None);
560        assert!(yaml.contains(r#"argocd.argoproj.io/sync-wave: "-5""#));
561    }
562
563    #[test]
564    fn test_no_keda_files_when_disabled() {
565        let mut contract = test_contract();
566        contract.keda = None;
567
568        let dir = tempfile::tempdir().unwrap();
569        generate_chart(&contract, dir.path(), None).unwrap();
570
571        assert!(!dir.path().join("templates/keda-scaledobject.yaml").exists());
572        assert!(!dir.path().join("templates/keda-triggerauth.yaml").exists());
573    }
574
575    #[test]
576    fn test_to_camel_suffix() {
577        assert_eq!(to_camel_suffix("kafka"), "kafka");
578        assert_eq!(to_camel_suffix("clickhouse"), "clickhouse");
579        assert_eq!(to_camel_suffix("click_house"), "clickHouse");
580        assert_eq!(to_camel_suffix("my-service"), "myService");
581    }
582
583    // ============================================================================
584    // Contract Identity Annotation Scheme v1 -- end-to-end wiring tests.
585    // The unit tests for ContractIdentity itself live in
586    // src/deployment/contract_identity.rs; these verify the three
587    // generators each emit the three keys in the right surface.
588    // ============================================================================
589
590    fn test_identity() -> crate::deployment::ContractIdentity {
591        crate::deployment::ContractIdentity::new(
592            "0123456789abcdef0123456789abcdef01234567",
593            "ghcr.io/hyperi-io/dfe-loader:v2.7.2",
594        )
595        .expect("test fixture must be valid")
596    }
597
598    #[test]
599    fn dockerfile_omits_identity_block_when_none() {
600        let dockerfile = generate_dockerfile(&test_contract(), None);
601        assert!(!dockerfile.contains("io.hyperi.contract"));
602    }
603
604    #[test]
605    fn dockerfile_emits_three_identity_labels_when_some() {
606        let id = test_identity();
607        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
608        assert!(dockerfile.contains("LABEL io.hyperi.contract.version=\"v1\""));
609        assert!(dockerfile.contains(
610            "LABEL io.hyperi.contract.source-commit=\"0123456789abcdef0123456789abcdef01234567\""
611        ));
612        assert!(dockerfile.contains(
613            "LABEL io.hyperi.contract.image-ref=\"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
614        ));
615        // The existing io.hyperi.profile label is unaffected.
616        assert!(dockerfile.contains("LABEL io.hyperi.profile=\"production\""));
617    }
618
619    #[test]
620    fn chart_yaml_omits_identity_block_when_none() {
621        let dir = tempfile::tempdir().unwrap();
622        generate_chart(&test_contract(), dir.path(), None).unwrap();
623        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
624        assert!(!chart.contains("io.hyperi.contract"));
625    }
626
627    #[test]
628    fn chart_yaml_emits_three_identity_annotations_when_some() {
629        let id = test_identity();
630        let dir = tempfile::tempdir().unwrap();
631        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
632        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
633        // Top-level annotations block present.
634        assert!(chart.contains("\nannotations:\n"));
635        assert!(chart.contains("io.hyperi.contract.version: \"v1\""));
636        assert!(chart.contains(
637            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
638        ));
639        assert!(
640            chart.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
641        );
642    }
643
644    #[test]
645    fn argocd_application_omits_identity_block_when_none() {
646        let argo = ArgocdConfig::default();
647        let yaml = generate_argocd_application(&test_contract(), &argo, None);
648        assert!(!yaml.contains("io.hyperi.contract"));
649        // sync-wave is unaffected.
650        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
651    }
652
653    #[test]
654    fn argocd_application_emits_three_identity_annotations_when_some() {
655        let id = test_identity();
656        let argo = ArgocdConfig::default();
657        let yaml = generate_argocd_application(&test_contract(), &argo, Some(&id));
658        // Both the existing sync-wave AND the three identity keys must appear
659        // under the same metadata.annotations block.
660        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
661        assert!(yaml.contains("io.hyperi.contract.version: \"v1\""));
662        assert!(yaml.contains(
663            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
664        ));
665        assert!(
666            yaml.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
667        );
668    }
669
670    #[test]
671    fn all_three_surfaces_share_the_same_key_prefix() {
672        let id = test_identity();
673        let argo = ArgocdConfig::default();
674        let dir = tempfile::tempdir().unwrap();
675
676        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
677        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
678        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
679        let app = generate_argocd_application(&test_contract(), &argo, Some(&id));
680
681        // The documented grep payoff: every surface mentions the prefix
682        // exactly three times (once per key).
683        assert_eq!(dockerfile.matches("io.hyperi.contract").count(), 3);
684        assert_eq!(chart.matches("io.hyperi.contract").count(), 3);
685        assert_eq!(app.matches("io.hyperi.contract").count(), 3);
686    }
687}