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_helpers_contain_secret_helpers() {
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("templates/_helpers.tpl")).unwrap();
289        assert!(content.contains("kafkaSecretName"));
290        assert!(content.contains("clickhouseSecretName"));
291    }
292
293    #[test]
294    fn test_deployment_contains_env_vars() {
295        let contract = test_contract();
296        let dir = tempfile::tempdir().unwrap();
297        generate_chart(&contract, dir.path(), None).unwrap();
298
299        let content =
300            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
301        assert!(content.contains("DFE_LOADER__KAFKA__USERNAME"));
302        assert!(content.contains("DFE_LOADER__KAFKA__PASSWORD"));
303        assert!(content.contains("DFE_LOADER__CLICKHOUSE__PASSWORD"));
304        assert!(content.contains("path: /healthz"));
305        assert!(content.contains("path: /readyz"));
306        assert!(content.contains("/etc/dfe"));
307    }
308
309    #[test]
310    fn test_is_go_identifier() {
311        // Valid Go identifiers
312        assert!(is_go_identifier("foo"));
313        assert!(is_go_identifier("FOO"));
314        assert!(is_go_identifier("foo_bar"));
315        assert!(is_go_identifier("_underscore_start"));
316        assert!(is_go_identifier("foo123"));
317        assert!(is_go_identifier("a"));
318
319        // Invalid -- would break Go templates
320        assert!(!is_go_identifier("bearer-tokens")); // hyphen
321        assert!(!is_go_identifier("foo.bar")); // dot
322        assert!(!is_go_identifier("123foo")); // digit-leading
323        assert!(!is_go_identifier("")); // empty
324        assert!(!is_go_identifier("foo bar")); // space
325        assert!(!is_go_identifier("foo:bar")); // colon
326    }
327
328    #[test]
329    fn test_safe_template_lookup_chooses_form() {
330        assert_eq!(
331            safe_template_lookup(".Values.auth", "username"),
332            ".Values.auth.username"
333        );
334        assert_eq!(
335            safe_template_lookup(".Values.auth", "bearer-tokens"),
336            "(index .Values.auth \"bearer-tokens\")"
337        );
338        assert_eq!(
339            safe_template_lookup(".Values.kafka.secretKeys", "kafka-username"),
340            "(index .Values.kafka.secretKeys \"kafka-username\")"
341        );
342    }
343
344    /// Regression for the dfe-receiver canary 2026-05-25 finding:
345    /// keda-scaledobject.yaml previously used
346    /// `default (index .Values.config.kafka.topics 0)` which `helm lint`
347    /// rejects with `error calling index: index of untyped nil` because
348    /// Sprig's `default` evaluates both operands. The render must now
349    /// use a conditional `if/else if/else` block instead.
350    #[test]
351    fn test_keda_scaledobject_topic_lookup_is_lint_safe() {
352        let contract = test_contract();
353        let dir = tempfile::tempdir().unwrap();
354        generate_chart(&contract, dir.path(), None).unwrap();
355
356        let keda_yaml =
357            std::fs::read_to_string(dir.path().join("templates/keda-scaledobject.yaml")).unwrap();
358
359        // Old broken form must not appear
360        assert!(
361            !keda_yaml.contains(
362                ".Values.keda.kafka.topic | default (index .Values.config.kafka.topics 0)"
363            ),
364            "keda-scaledobject.yaml still uses the eagerly-evaluated `default (index ...)` form:\n{keda_yaml}"
365        );
366
367        // New conditional form must appear
368        assert!(
369            keda_yaml.contains("if .Values.keda.kafka.topic"),
370            "keda-scaledobject.yaml missing if/else guard for topic lookup:\n{keda_yaml}"
371        );
372        assert!(
373            keda_yaml.contains("else if .Values.config.kafka.topics"),
374            "keda-scaledobject.yaml missing fallback branch for config.kafka.topics:\n{keda_yaml}"
375        );
376    }
377
378    /// Regression for the dfe-receiver canary 2026-05-25 finding:
379    /// secret.yaml previously emitted `.Values.x.bearer-tokens` which
380    /// Go templates reject ("bad character U+002D '-'"). The render
381    /// must now use the `(index .Values.x "bearer-tokens")` form.
382    #[test]
383    fn test_secret_yaml_handles_hyphenated_key_names() {
384        let mut contract = test_contract();
385        // dfe-receiver-style hyphenated key_name (token group)
386        contract.secrets.push(SecretGroupContract {
387            group_name: "auth".into(),
388            env_vars: vec![SecretEnvContract {
389                env_var: "DFE_RECEIVER__AUTH__BEARER_TOKENS".into(),
390                key_name: "bearer-tokens".into(),
391                secret_key: "bearer-tokens".into(),
392            }],
393        });
394
395        let dir = tempfile::tempdir().unwrap();
396        generate_chart(&contract, dir.path(), None).unwrap();
397
398        let secret_yaml =
399            std::fs::read_to_string(dir.path().join("templates/secret.yaml")).unwrap();
400        let deployment_yaml =
401            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
402
403        // Old broken form must not appear anywhere
404        assert!(
405            !secret_yaml.contains(".Values.auth.bearer-tokens"),
406            "secret.yaml still uses broken dot-walked form for hyphenated key:\n{secret_yaml}"
407        );
408        assert!(
409            !secret_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
410            "secret.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{secret_yaml}"
411        );
412        assert!(
413            !deployment_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
414            "deployment.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{deployment_yaml}"
415        );
416
417        // Safe index form must appear
418        assert!(
419            secret_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
420            "secret.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{secret_yaml}"
421        );
422        assert!(
423            secret_yaml.contains("(index .Values.auth \"bearer-tokens\")"),
424            "secret.yaml missing index-form lookup for value bearer-tokens:\n{secret_yaml}"
425        );
426        assert!(
427            deployment_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
428            "deployment.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{deployment_yaml}"
429        );
430
431        // Sanity: Go-safe keys (e.g. existing kafka.username) still use dot form
432        assert!(
433            secret_yaml.contains(".Values.kafka.secretKeys.username"),
434            "Go-safe key 'username' should still use dot-walked form:\n{secret_yaml}"
435        );
436    }
437
438    #[test]
439    fn test_generate_argocd_application_default() {
440        let contract = test_contract();
441        let argo = ArgocdConfig {
442            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
443            ..Default::default()
444        };
445        let yaml = generate_argocd_application(&contract, &argo, None);
446
447        assert!(yaml.contains("apiVersion: argoproj.io/v1alpha1"));
448        assert!(yaml.contains("kind: Application"));
449        assert!(yaml.contains("name: dfe-loader"));
450        assert!(yaml.contains("namespace: argocd"));
451        assert!(yaml.contains("repoURL: https://github.com/hyperi-io/dfe-loader"));
452        assert!(yaml.contains("targetRevision: main"));
453        assert!(yaml.contains("path: chart"));
454        assert!(yaml.contains("CreateNamespace=true"));
455        assert!(yaml.contains("Schema version: "));
456    }
457
458    #[test]
459    fn test_generate_argocd_custom_namespace_and_path() {
460        let contract = test_contract();
461        let argo = ArgocdConfig {
462            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
463            dest_namespace: "production".into(),
464            chart_path: "deploy/chart".into(),
465            target_revision: "v1.0.0".into(),
466            sync_wave: 5,
467            ..Default::default()
468        };
469        let yaml = generate_argocd_application(&contract, &argo, None);
470        assert!(yaml.contains("namespace: production"));
471        assert!(yaml.contains("path: deploy/chart"));
472        assert!(yaml.contains("targetRevision: v1.0.0"));
473        assert!(yaml.contains("sync-wave: \"5\""));
474    }
475
476    #[test]
477    fn argocd_config_default_uses_wave_apps() {
478        let cfg = ArgocdConfig::default();
479        assert_eq!(cfg.sync_wave, crate::deployment::WAVE_APPS);
480    }
481
482    #[test]
483    fn argocd_config_default_has_no_extra_ignore_differences() {
484        let cfg = ArgocdConfig::default();
485        assert!(cfg.extra_ignore_differences.is_empty());
486    }
487
488    #[test]
489    fn generate_argocd_application_emits_default_ignore_differences() {
490        let contract = test_contract();
491        let argo = ArgocdConfig {
492            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
493            ..Default::default()
494        };
495        let yaml = generate_argocd_application(&contract, &argo, None);
496        assert!(yaml.contains("ignoreDifferences:"));
497        assert!(yaml.contains("/spec/replicas"));
498        assert!(yaml.contains("/spec/clusterIP"));
499        assert!(yaml.contains(".webhooks[].clientConfig.caBundle"));
500    }
501
502    #[test]
503    fn generate_argocd_application_appends_extra_ignore_differences() {
504        let contract = test_contract();
505        let argo = ArgocdConfig {
506            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
507            extra_ignore_differences: vec![
508                "- group: apps\n  kind: Deployment\n  jsonPointers:\n    - /spec/template/spec/containers/0/image".into(),
509            ],
510            ..Default::default()
511        };
512        let yaml = generate_argocd_application(&contract, &argo, None);
513        assert!(yaml.contains("/spec/template/spec/containers/0/image"));
514    }
515
516    #[test]
517    fn generate_argocd_application_sync_wave_annotation_uses_config_value() {
518        let contract = test_contract();
519        let argo = ArgocdConfig {
520            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
521            sync_wave: crate::deployment::WAVE_TOPICS,
522            ..Default::default()
523        };
524        let yaml = generate_argocd_application(&contract, &argo, None);
525        assert!(yaml.contains(r#"argocd.argoproj.io/sync-wave: "-5""#));
526    }
527
528    #[test]
529    fn test_no_keda_files_when_disabled() {
530        let mut contract = test_contract();
531        contract.keda = None;
532
533        let dir = tempfile::tempdir().unwrap();
534        generate_chart(&contract, dir.path(), None).unwrap();
535
536        assert!(!dir.path().join("templates/keda-scaledobject.yaml").exists());
537        assert!(!dir.path().join("templates/keda-triggerauth.yaml").exists());
538    }
539
540    #[test]
541    fn test_to_camel_suffix() {
542        assert_eq!(to_camel_suffix("kafka"), "kafka");
543        assert_eq!(to_camel_suffix("clickhouse"), "clickhouse");
544        assert_eq!(to_camel_suffix("click_house"), "clickHouse");
545        assert_eq!(to_camel_suffix("my-service"), "myService");
546    }
547
548    // ============================================================================
549    // Contract Identity Annotation Scheme v1 -- end-to-end wiring tests.
550    // The unit tests for ContractIdentity itself live in
551    // src/deployment/contract_identity.rs; these verify the three
552    // generators each emit the three keys in the right surface.
553    // ============================================================================
554
555    fn test_identity() -> crate::deployment::ContractIdentity {
556        crate::deployment::ContractIdentity::new(
557            "0123456789abcdef0123456789abcdef01234567",
558            "ghcr.io/hyperi-io/dfe-loader:v2.7.2",
559        )
560        .expect("test fixture must be valid")
561    }
562
563    #[test]
564    fn dockerfile_omits_identity_block_when_none() {
565        let dockerfile = generate_dockerfile(&test_contract(), None);
566        assert!(!dockerfile.contains("io.hyperi.contract"));
567    }
568
569    #[test]
570    fn dockerfile_emits_three_identity_labels_when_some() {
571        let id = test_identity();
572        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
573        assert!(dockerfile.contains("LABEL io.hyperi.contract.version=\"v1\""));
574        assert!(dockerfile.contains(
575            "LABEL io.hyperi.contract.source-commit=\"0123456789abcdef0123456789abcdef01234567\""
576        ));
577        assert!(dockerfile.contains(
578            "LABEL io.hyperi.contract.image-ref=\"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
579        ));
580        // The existing io.hyperi.profile label is unaffected.
581        assert!(dockerfile.contains("LABEL io.hyperi.profile=\"production\""));
582    }
583
584    #[test]
585    fn chart_yaml_omits_identity_block_when_none() {
586        let dir = tempfile::tempdir().unwrap();
587        generate_chart(&test_contract(), dir.path(), None).unwrap();
588        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
589        assert!(!chart.contains("io.hyperi.contract"));
590    }
591
592    #[test]
593    fn chart_yaml_emits_three_identity_annotations_when_some() {
594        let id = test_identity();
595        let dir = tempfile::tempdir().unwrap();
596        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
597        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
598        // Top-level annotations block present.
599        assert!(chart.contains("\nannotations:\n"));
600        assert!(chart.contains("io.hyperi.contract.version: \"v1\""));
601        assert!(chart.contains(
602            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
603        ));
604        assert!(
605            chart.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
606        );
607    }
608
609    #[test]
610    fn argocd_application_omits_identity_block_when_none() {
611        let argo = ArgocdConfig::default();
612        let yaml = generate_argocd_application(&test_contract(), &argo, None);
613        assert!(!yaml.contains("io.hyperi.contract"));
614        // sync-wave is unaffected.
615        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
616    }
617
618    #[test]
619    fn argocd_application_emits_three_identity_annotations_when_some() {
620        let id = test_identity();
621        let argo = ArgocdConfig::default();
622        let yaml = generate_argocd_application(&test_contract(), &argo, Some(&id));
623        // Both the existing sync-wave AND the three identity keys must appear
624        // under the same metadata.annotations block.
625        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
626        assert!(yaml.contains("io.hyperi.contract.version: \"v1\""));
627        assert!(yaml.contains(
628            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
629        ));
630        assert!(
631            yaml.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
632        );
633    }
634
635    #[test]
636    fn all_three_surfaces_share_the_same_key_prefix() {
637        let id = test_identity();
638        let argo = ArgocdConfig::default();
639        let dir = tempfile::tempdir().unwrap();
640
641        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
642        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
643        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
644        let app = generate_argocd_application(&test_contract(), &argo, Some(&id));
645
646        // The documented grep payoff: every surface mentions the prefix
647        // exactly three times (once per key).
648        assert_eq!(dockerfile.matches("io.hyperi.contract").count(), 3);
649        assert_eq!(chart.matches("io.hyperi.contract").count(), 3);
650        assert_eq!(app.matches("io.hyperi.contract").count(), 3);
651    }
652}