Skip to main content

hyperi_rustlib/deployment/
generate.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/generate.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//! from a [`DeploymentContract`].
11//!
12//! Apps provide ~20% customisation (ports, secrets, config); this module
13//! generates ~80% boilerplate (Dockerfile, Helm chart, Compose fragment).
14
15#![allow(clippy::format_push_string)] // Template generators naturally build strings via push_str(&format!(...))
16
17use std::path::Path;
18
19use super::contract::{DeploymentContract, ImageProfile};
20use super::error::DeploymentError;
21
22// ============================================================================
23// Dockerfile
24// ============================================================================
25
26/// Generate a Dockerfile from the deployment contract.
27///
28/// When `native_deps` is populated (via [`NativeDepsContract::for_rustlib_features`](crate::deployment::NativeDepsContract::for_rustlib_features)),
29/// the generated Dockerfile automatically includes custom APT repo setup and
30/// runtime package installation. If `native_deps` is empty, only base utilities
31/// are installed.
32///
33/// `identity`, when provided, stamps three `io.hyperi.contract.*` LABEL
34/// lines on the image per the Contract Identity Annotation Scheme v1
35/// (see [`ContractIdentity`](crate::deployment::ContractIdentity)). Phase 1
36/// rollout: optional; callers SHOULD pass `Some(&identity)`. Phase 2 will
37/// flip this to a required parameter.
38#[must_use]
39pub fn generate_dockerfile(
40    contract: &DeploymentContract,
41    identity: Option<&crate::deployment::ContractIdentity>,
42) -> String {
43    let binary = contract.binary();
44
45    // EXPOSE line: metrics_port + extra ports
46    let expose_ports = {
47        let mut ports = vec![contract.metrics_port.to_string()];
48        for p in &contract.extra_ports {
49            ports.push(p.port.to_string());
50        }
51        ports.join(" ")
52    };
53
54    // CMD line
55    let cmd = if contract.entrypoint_args.is_empty() {
56        String::new()
57    } else {
58        let args: Vec<String> = contract
59            .entrypoint_args
60            .iter()
61            .map(|a| format!("\"{a}\""))
62            .collect();
63        format!("\nCMD [{}]", args.join(", "))
64    };
65
66    // Build the apt-get RUN block dynamically from native_deps + image profile
67    let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);
68
69    let profile_label = match contract.image_profile {
70        ImageProfile::Production => "production",
71        ImageProfile::Development => "development",
72    };
73
74    // Contract Identity Annotation Scheme v1 -- three LABEL lines.
75    // Phase 1: silent on None for backwards compat (see module doc).
76    let identity_block = identity
77        .map(|id| format!("\n{labels}", labels = id.as_dockerfile_labels()))
78        .unwrap_or_default();
79
80    format!(
81        r#"# Project:   {app_name}
82# File:      Dockerfile
83# Purpose:   {profile_label} container image
84#
85# License:   BUSL-1.1
86# Copyright: (c) 2026 HYPERI PTY LIMITED
87#
88# AUTOGENERATED -- do not edit by hand.
89# Generated by hyperi-rustlib::deployment::generate_dockerfile()
90# Schema version: {schema_version}
91# Source contract: {app_name}::deployment::contract()
92# Regenerate with: `{binary} emit-dockerfile > Dockerfile`
93
94FROM {base_image}
95
96LABEL io.hyperi.profile="{profile_label}"{identity_block}
97
98{apt_block}
99COPY {binary} /usr/local/bin/{binary}
100RUN chmod +x /usr/local/bin/{binary}
101
102# Ubuntu 24.04 ships with ubuntu user at UID 1000 -- remove before creating appuser
103RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
104USER appuser
105
106EXPOSE {expose_ports}
107
108HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
109    CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1
110
111ENTRYPOINT ["{binary}"]{cmd}
112"#,
113        app_name = contract.app_name,
114        base_image = contract.base_image,
115        binary = binary,
116        profile_label = profile_label,
117        apt_block = apt_block,
118        expose_ports = expose_ports,
119        metrics_port = contract.metrics_port,
120        liveness_path = contract.health.liveness_path,
121        cmd = cmd,
122        schema_version = contract.schema_version,
123        identity_block = identity_block,
124    )
125}
126
127// ============================================================================
128// Container Manifest (CI-consumable JSON)
129// ============================================================================
130
131/// Generate a container manifest JSON for CI consumption.
132///
133/// This is the minimal subset of the deployment contract that CI needs to
134/// build the container image. No secrets, no K8s-specific config.
135///
136/// # Errors
137///
138/// Returns an error string if JSON serialisation fails.
139pub fn generate_container_manifest(contract: &DeploymentContract) -> Result<String, String> {
140    let binary = contract.binary();
141
142    let apt_repos: Vec<serde_json::Value> = contract
143        .native_deps
144        .apt_repos
145        .iter()
146        .map(|r| {
147            serde_json::json!({
148                "key_url": r.key_url,
149                "keyring": r.keyring,
150                "url": r.url,
151                "codename": r.codename,
152                "packages": r.packages,
153            })
154        })
155        .collect();
156
157    let mut expose_ports: Vec<u16> = vec![contract.metrics_port];
158    expose_ports.extend(contract.extra_ports.iter().map(|p| p.port));
159
160    let profile_str = match contract.image_profile {
161        ImageProfile::Production => "production",
162        ImageProfile::Development => "development",
163    };
164
165    let title = if contract.oci_labels.title.is_empty() {
166        &contract.app_name
167    } else {
168        &contract.oci_labels.title
169    };
170
171    let manifest = serde_json::json!({
172        "schema_version": "1",
173        "app_name": contract.app_name,
174        "binary_name": binary,
175        "base_image": contract.base_image,
176        "image_registry": contract.image_registry,
177        "image_profile": profile_str,
178        "runtime_packages": {
179            "apt_repos": apt_repos,
180            "apt_packages": contract.native_deps.apt_packages,
181        },
182        "expose_ports": expose_ports,
183        "healthcheck": {
184            "path": contract.health.liveness_path,
185            "port": contract.metrics_port,
186            "interval": "30s",
187            "timeout": "3s",
188            "start_period": "5s",
189            "retries": 3,
190        },
191        "entrypoint": [binary],
192        "cmd": contract.entrypoint_args,
193        "user": "appuser",
194        "uid": 1000,
195        "labels": {
196            "io.hyperi.profile": profile_str,
197            "io.hyperi.app": contract.app_name,
198            "io.hyperi.metrics_port": contract.metrics_port.to_string(),
199            "org.opencontainers.image.title": title,
200            "org.opencontainers.image.description": contract.oci_labels.description,
201            "org.opencontainers.image.vendor": contract.oci_labels.vendor,
202            "org.opencontainers.image.licenses": contract.oci_labels.licenses,
203        },
204    });
205
206    serde_json::to_string_pretty(&manifest)
207        .map_err(|e| format!("container manifest JSON failed: {e}"))
208}
209
210// ============================================================================
211// Runtime Stage Fragment (for CI Dockerfile composition)
212// ============================================================================
213
214/// Generate only the runtime stage of a Dockerfile as a fragment.
215///
216/// CI composes the full Dockerfile by prepending its own build stages
217/// (cargo-chef pattern) and appending this runtime stage. This keeps
218/// the boundary clean: rustlib owns what's *in* the container, CI owns
219/// how to *build* the binary.
220#[must_use]
221pub fn generate_runtime_stage(contract: &DeploymentContract) -> String {
222    let binary = contract.binary();
223    let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);
224
225    let profile_label = match contract.image_profile {
226        ImageProfile::Production => "production",
227        ImageProfile::Development => "development",
228    };
229
230    let title = if contract.oci_labels.title.is_empty() {
231        &contract.app_name
232    } else {
233        &contract.oci_labels.title
234    };
235
236    let expose_ports = {
237        let mut ports = vec![contract.metrics_port.to_string()];
238        for p in &contract.extra_ports {
239            ports.push(p.port.to_string());
240        }
241        ports.join(" ")
242    };
243
244    let cmd = if contract.entrypoint_args.is_empty() {
245        String::new()
246    } else {
247        let args: Vec<String> = contract
248            .entrypoint_args
249            .iter()
250            .map(|a| format!("\"{a}\""))
251            .collect();
252        format!("\nCMD [{}]", args.join(", "))
253    };
254
255    format!(
256        r#"# --- Runtime stage (generated by hyperi-rustlib deployment contract) ---
257FROM {base_image} AS runtime
258
259# Static OCI labels (from contract)
260LABEL org.opencontainers.image.title="{title}"
261LABEL org.opencontainers.image.description="{description}"
262LABEL org.opencontainers.image.vendor="{vendor}"
263LABEL org.opencontainers.image.licenses="{licenses}"
264LABEL io.hyperi.profile="{profile_label}"
265
266{apt_block}
267# Dynamic OCI labels (injected by CI at build time)
268ARG OCI_SOURCE=""
269ARG OCI_REVISION=""
270ARG OCI_VERSION=""
271ARG OCI_CREATED=""
272LABEL org.opencontainers.image.source="${{OCI_SOURCE}}"
273LABEL org.opencontainers.image.revision="${{OCI_REVISION}}"
274LABEL org.opencontainers.image.version="${{OCI_VERSION}}"
275LABEL org.opencontainers.image.created="${{OCI_CREATED}}"
276
277COPY --from=builder /app/target/release/{binary} /usr/local/bin/{binary}
278RUN chmod +x /usr/local/bin/{binary}
279
280RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
281USER appuser
282
283EXPOSE {expose_ports}
284
285HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
286    CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1
287
288ENTRYPOINT ["{binary}"]{cmd}
289"#,
290        base_image = contract.base_image,
291        title = title,
292        description = contract.oci_labels.description,
293        vendor = contract.oci_labels.vendor,
294        licenses = contract.oci_labels.licenses,
295        profile_label = profile_label,
296        apt_block = apt_block,
297        binary = binary,
298        expose_ports = expose_ports,
299        metrics_port = contract.metrics_port,
300        liveness_path = contract.health.liveness_path,
301        cmd = cmd,
302    )
303}
304
305/// Diagnostic tools installed in development images.
306const DEV_TOOLS: &[&str] = &[
307    "bash",
308    "strace",
309    "tcpdump",
310    "procps",
311    "dnsutils",
312    "net-tools",
313    "less",
314    "jq",
315];
316
317/// Build the apt-get RUN block from native deps contract and image profile.
318///
319/// When custom APT repos are needed (e.g., Confluent for librdkafka), emits
320/// the GPG key download, sources list entry, and repo-specific packages.
321/// Development profile adds diagnostic tools (strace, tcpdump, etc.).
322fn build_apt_block(deps: &super::native_deps::NativeDepsContract, profile: ImageProfile) -> String {
323    let mut out = String::with_capacity(512);
324    let is_dev = profile == ImageProfile::Development;
325
326    // Base packages always installed (curl needed for healthcheck, ca-certificates for TLS)
327    let mut base_pkgs = vec!["ca-certificates", "curl", "netcat-openbsd", "iputils-ping"];
328
329    // If we have custom APT repos, we need gnupg for key import
330    if !deps.apt_repos.is_empty() {
331        base_pkgs.push("gnupg");
332    }
333
334    // Dev profile adds diagnostic tools
335    if is_dev {
336        base_pkgs.extend_from_slice(DEV_TOOLS);
337    }
338
339    if deps.is_empty() {
340        // No native deps -- simple install
341        out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
342        out.push_str(&format!("    {} \\\n", base_pkgs.join(" ")));
343        out.push_str("    && rm -rf /var/lib/apt/lists/*\n");
344        return out;
345    }
346
347    // Collect all runtime packages (repo-specific + default)
348    let mut runtime_pkgs: Vec<&str> = Vec::new();
349    for repo in &deps.apt_repos {
350        for pkg in &repo.packages {
351            runtime_pkgs.push(pkg);
352        }
353    }
354    for pkg in &deps.apt_packages {
355        runtime_pkgs.push(pkg);
356    }
357
358    // Build multi-step RUN: base install → repo setup → update → runtime install → cleanup
359    out.push_str("# Runtime shared libraries for dynamically-linked Rust crates.\n");
360
361    out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
362    out.push_str(&format!("    {} \\\n", base_pkgs.join(" ")));
363
364    // Add each custom APT repo
365    for repo in &deps.apt_repos {
366        out.push_str(&format!(
367            "    && curl -fsSL {} \\\n\
368             \x20      | gpg --dearmor -o {} \\\n\
369             \x20   && echo \"deb [signed-by={}] \\\n\
370             \x20      {} {} main\" \\\n\
371             \x20      > /etc/apt/sources.list.d/{}.list \\\n",
372            repo.key_url,
373            repo.keyring,
374            repo.keyring,
375            repo.url,
376            repo.codename,
377            // Derive a stable filename from the keyring path
378            std::path::Path::new(&repo.keyring)
379                .file_stem()
380                .and_then(|s| s.to_str())
381                .unwrap_or("custom-repo"),
382        ));
383    }
384
385    // Second apt-get update + install runtime packages
386    out.push_str("    && apt-get update && apt-get install -y --no-install-recommends \\\n");
387    out.push_str(&format!("       {} \\\n", runtime_pkgs.join(" ")));
388    out.push_str("    && rm -rf /var/lib/apt/lists/*\n");
389
390    out
391}
392
393// ============================================================================
394// Docker Compose fragment
395// ============================================================================
396
397/// Generate a Docker Compose service fragment from the deployment contract.
398#[must_use]
399pub fn generate_compose_fragment(contract: &DeploymentContract) -> String {
400    let binary = contract.binary();
401    let mut out = String::with_capacity(512);
402
403    // Service definition
404    out.push_str(&format!(
405        "# Generated by hyperi-rustlib deployment module\nservices:\n  {}:\n",
406        contract.app_name
407    ));
408
409    // Image
410    out.push_str(&format!(
411        "    image: {}/{}:${{{}_VERSION:-latest}}\n",
412        contract.image_registry,
413        contract.app_name,
414        contract.env_prefix.replace("__", "_")
415    ));
416
417    // depends_on
418    if !contract.depends_on.is_empty() {
419        out.push_str("    depends_on:\n");
420        for dep in &contract.depends_on {
421            out.push_str(&format!(
422                "      {dep}:\n        condition: service_healthy\n"
423            ));
424        }
425    }
426
427    // Ports
428    out.push_str("    ports:\n");
429    out.push_str(&format!(
430        "      - \"{}:{}\"\n",
431        contract.metrics_port, contract.metrics_port
432    ));
433    for p in &contract.extra_ports {
434        out.push_str(&format!("      - \"{}:{}\"\n", p.port, p.port));
435    }
436
437    // Volumes -- config file mount
438    out.push_str("    volumes:\n");
439    out.push_str(&format!(
440        "      - ./config/{}:{}:ro\n",
441        contract.config_filename(),
442        contract.config_mount_path,
443    ));
444
445    // Healthcheck
446    out.push_str(&format!(
447        "    healthcheck:\n\
448         \x20     test: [\"CMD\", \"curl\", \"-sf\", \"http://localhost:{}{}\"]
449      interval: 10s\n\
450         \x20     timeout: 3s\n\
451         \x20     retries: 5\n",
452        contract.metrics_port, contract.health.liveness_path,
453    ));
454
455    // Entrypoint args
456    if !contract.entrypoint_args.is_empty() {
457        out.push_str(&format!("    command: [\"{binary}\""));
458        for arg in &contract.entrypoint_args {
459            out.push_str(&format!(", \"{arg}\""));
460        }
461        out.push_str("]\n");
462    }
463
464    out
465}
466
467// ============================================================================
468// Helm chart
469// ============================================================================
470
471/// Generate a complete Helm chart directory from the deployment contract.
472///
473/// Writes `Chart.yaml`, `values.yaml`, and all template files to `output_dir`.
474///
475/// `identity`, when provided, stamps the three `io.hyperi.contract.*`
476/// annotations into `Chart.yaml`'s top-level `annotations:` block per the
477/// Contract Identity Annotation Scheme v1. Phase 1 rollout: optional;
478/// callers SHOULD pass `Some(&identity)`.
479///
480/// # Errors
481///
482/// Returns `DeploymentError` if files or directories cannot be created.
483pub fn generate_chart(
484    contract: &DeploymentContract,
485    output_dir: impl AsRef<Path>,
486    identity: Option<&crate::deployment::ContractIdentity>,
487) -> Result<(), DeploymentError> {
488    let dir = output_dir.as_ref();
489    let templates_dir = dir.join("templates");
490
491    // Create directories
492    std::fs::create_dir_all(&templates_dir).map_err(|e| DeploymentError::CreateDir {
493        path: templates_dir.display().to_string(),
494        source: e,
495    })?;
496
497    // Write all chart files
498    write_file(dir.join("Chart.yaml"), &gen_chart_yaml(contract, identity))?;
499    write_file(dir.join("values.yaml"), &gen_values_yaml(contract))?;
500    write_file(
501        templates_dir.join("_helpers.tpl"),
502        &gen_helpers_tpl(contract),
503    )?;
504    write_file(
505        templates_dir.join("deployment.yaml"),
506        &gen_deployment_yaml(contract),
507    )?;
508    write_file(
509        templates_dir.join("service.yaml"),
510        &gen_service_yaml(contract),
511    )?;
512    write_file(
513        templates_dir.join("serviceaccount.yaml"),
514        &gen_serviceaccount_yaml(contract),
515    )?;
516    write_file(
517        templates_dir.join("configmap.yaml"),
518        &gen_configmap_yaml(contract),
519    )?;
520    write_file(
521        templates_dir.join("secret.yaml"),
522        &gen_secret_yaml(contract),
523    )?;
524    write_file(templates_dir.join("hpa.yaml"), &gen_hpa_yaml(contract))?;
525
526    if contract.keda.is_some() {
527        write_file(
528            templates_dir.join("keda-scaledobject.yaml"),
529            &gen_keda_scaledobject_yaml(contract),
530        )?;
531        write_file(
532            templates_dir.join("keda-triggerauth.yaml"),
533            &gen_keda_triggerauth_yaml(contract),
534        )?;
535    }
536
537    write_file(templates_dir.join("NOTES.txt"), &gen_notes_txt(contract))?;
538
539    Ok(())
540}
541
542// ============================================================================
543// Chart file generators
544// ============================================================================
545
546fn gen_chart_yaml(
547    c: &DeploymentContract,
548    identity: Option<&crate::deployment::ContractIdentity>,
549) -> String {
550    // Contract Identity Annotation Scheme v1 -- top-level annotations block.
551    let identity_block = identity
552        .map(|id| format!("\nannotations:\n{ann}\n", ann = id.as_yaml_annotations(2)))
553        .unwrap_or_default();
554
555    format!(
556        "apiVersion: v2\n\
557         name: {name}\n\
558         description: {desc}\n\
559         type: application\n\
560         version: 0.1.0\n\
561         appVersion: \"1.0.0\"\n\
562         {identity_block}\n\
563         keywords:\n\
564         \x20 - hyperi\n\
565         \x20 - dfe\n\
566         \n\
567         maintainers:\n\
568         \x20 - name: HyperI\n\
569         \x20   url: https://github.com/hyperi-io\n",
570        name = c.app_name,
571        desc = if c.description.is_empty() {
572            &c.app_name
573        } else {
574            &c.description
575        },
576        identity_block = identity_block,
577    )
578}
579
580#[allow(clippy::too_many_lines)]
581fn gen_values_yaml(c: &DeploymentContract) -> String {
582    let mut out = String::with_capacity(2048);
583
584    // Header comment
585    out.push_str(&format!(
586        "# {app} Helm chart values\n\
587         #\n\
588         # Generated by hyperi-rustlib deployment module.\n\
589         # Contract points validated by cargo test.\n\
590         \n",
591        app = c.app_name,
592    ));
593
594    // Replicas, image, overrides
595    out.push_str(&format!(
596        "# -- Number of replicas (ignored when KEDA is enabled)\n\
597         replicaCount: 1\n\
598         \n\
599         image:\n\
600         \x20 repository: {registry}/{app}\n\
601         \x20 # -- Defaults to Chart appVersion\n\
602         \x20 tag: \"\"\n\
603         \x20 pullPolicy: IfNotPresent\n\
604         \n\
605         imagePullSecrets: []\n\
606         nameOverride: \"\"\n\
607         fullnameOverride: \"\"\n\
608         \n",
609        registry = c.image_registry,
610        app = c.app_name,
611    ));
612
613    // Service account
614    out.push_str(
615        "serviceAccount:\n\
616         \x20 create: true\n\
617         \x20 annotations: {}\n\
618         \x20 # -- If not set, name is generated from fullname\n\
619         \x20 name: \"\"\n\
620         \n",
621    );
622
623    // Pod annotations (Prometheus)
624    out.push_str(&format!(
625        "# -- Pod annotations (Prometheus scrape config included by default)\n\
626         podAnnotations:\n\
627         \x20 prometheus.io/scrape: \"true\"\n\
628         \x20 prometheus.io/port: \"{port}\"\n\
629         \x20 prometheus.io/path: \"{metrics_path}\"\n\
630         \n\
631         podLabels: {{}}\n\
632         \n",
633        port = c.metrics_port,
634        metrics_path = c.health.metrics_path,
635    ));
636
637    // Resources
638    out.push_str(
639        "resources:\n\
640         \x20 requests:\n\
641         \x20   cpu: 250m\n\
642         \x20   memory: 256Mi\n\
643         \x20 limits:\n\
644         \x20   cpu: \"2\"\n\
645         \x20   memory: 1Gi\n\
646         \n",
647    );
648
649    // Service
650    out.push_str(&format!(
651        "# -- Metrics and health endpoint service\n\
652         service:\n\
653         \x20 type: ClusterIP\n\
654         \x20 port: {port}\n\
655         \n",
656        port = c.metrics_port,
657    ));
658
659    // App config section
660    out.push_str(&format!(
661        "# -- Application configuration (mounted as {})\n",
662        c.config_mount_path
663    ));
664    if let Some(ref config) = c.default_config {
665        out.push_str("config:\n");
666        // Serialise the config value as YAML and indent by 2
667        if let Ok(yaml) = serde_yaml_ng::to_string(config) {
668            for line in yaml.lines() {
669                if line == "---" {
670                    continue;
671                }
672                out.push_str(&format!("  {line}\n"));
673            }
674        }
675    } else {
676        out.push_str("config: {}\n");
677    }
678    out.push('\n');
679
680    // Secret sections
681    for group in &c.secrets {
682        out.push_str(&format!(
683            "# -- {} credentials\n\
684             {}:\n\
685             \x20 existingSecret: \"\"\n\
686             \x20 secretKeys:\n",
687            group.group_name, group.group_name,
688        ));
689        for env in &group.env_vars {
690            out.push_str(&format!("    {}: {}\n", env.key_name, env.secret_key));
691        }
692        for env in &group.env_vars {
693            out.push_str(&format!("  {}: \"\"\n", env.key_name));
694        }
695        out.push('\n');
696    }
697
698    // KEDA section. Always emit a `keda:` block in values.yaml even when
699    // the contract has no KEDA config -- templates reference
700    // `.Values.keda.enabled` unconditionally, so the key must exist or
701    // `helm lint` panics with "nil pointer evaluating interface
702    // {}.enabled". When the contract opts out, the block is just
703    // `enabled: false`.
704    if let Some(ref keda) = c.keda {
705        out.push_str(&format!(
706            "# -- KEDA autoscaling (requires KEDA operator installed)\n\
707             keda:\n\
708             \x20 enabled: true\n\
709             \x20 minReplicaCount: {min}\n\
710             \x20 maxReplicaCount: {max}\n\
711             \x20 pollingInterval: {poll}\n\
712             \x20 cooldownPeriod: {cool}\n\
713             \x20 kafka:\n\
714             \x20   # -- Scale when consumer group lag exceeds this per partition\n\
715             \x20   lagThreshold: \"{lag}\"\n\
716             \x20   # -- Wake from zero replicas when lag exceeds this\n\
717             \x20   activationLagThreshold: \"{activation}\"\n\
718             \x20   # -- Override topic (default: first topic from config)\n\
719             \x20   topic: \"\"\n\
720             \x20   # -- Override consumer group (default: from config)\n\
721             \x20   consumerGroup: \"\"\n\
722             \x20 cpu:\n\
723             \x20   enabled: {cpu_enabled}\n\
724             \x20   # -- CPU utilisation percentage threshold\n\
725             \x20   threshold: \"{cpu_threshold}\"\n\
726             \n",
727            min = keda.min_replicas,
728            max = keda.max_replicas,
729            poll = keda.polling_interval,
730            cool = keda.cooldown_period,
731            lag = keda.kafka_lag_threshold,
732            activation = keda.activation_lag_threshold,
733            cpu_enabled = keda.cpu_enabled,
734            cpu_threshold = keda.cpu_threshold,
735        ));
736    } else {
737        out.push_str(
738            "# -- KEDA autoscaling disabled by contract; HPA fallback below.\n\
739             keda:\n\
740             \x20 enabled: false\n\
741             \n",
742        );
743    }
744
745    // HPA fallback
746    out.push_str(
747        "# -- Standard HPA fallback (when KEDA is not installed)\n\
748         # Mutually exclusive with keda.enabled\n\
749         autoscaling:\n\
750         \x20 enabled: false\n\
751         \x20 minReplicas: 1\n\
752         \x20 maxReplicas: 10\n\
753         \x20 targetCPUUtilizationPercentage: 80\n\
754         \n\
755         nodeSelector: {}\n\
756         tolerations: []\n\
757         affinity: {}\n",
758    );
759
760    out
761}
762
763fn gen_helpers_tpl(c: &DeploymentContract) -> String {
764    let app = &c.app_name;
765    let mut out = String::with_capacity(2048);
766
767    // Standard helpers
768    out.push_str(&format!(
769        r#"{{{{/*
770Expand the name of the chart.
771*/}}}}
772{{{{- define "{app}.name" -}}}}
773{{{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}}}
774{{{{- end }}}}
775
776{{{{/*
777Create a default fully qualified app name.
778Truncated at 63 chars because some K8s name fields are limited.
779*/}}}}
780{{{{- define "{app}.fullname" -}}}}
781{{{{- if .Values.fullnameOverride }}}}
782{{{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}}}
783{{{{- else }}}}
784{{{{- $name := default .Chart.Name .Values.nameOverride }}}}
785{{{{- if contains $name .Release.Name }}}}
786{{{{- .Release.Name | trunc 63 | trimSuffix "-" }}}}
787{{{{- else }}}}
788{{{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}}}
789{{{{- end }}}}
790{{{{- end }}}}
791{{{{- end }}}}
792
793{{{{/*
794Create chart name and version as used by the chart label.
795*/}}}}
796{{{{- define "{app}.chart" -}}}}
797{{{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}}}
798{{{{- end }}}}
799
800{{{{/*
801Common labels.
802*/}}}}
803{{{{- define "{app}.labels" -}}}}
804helm.sh/chart: {{{{ include "{app}.chart" . }}}}
805{{{{ include "{app}.selectorLabels" . }}}}
806{{{{- if .Chart.AppVersion }}}}
807app.kubernetes.io/version: {{{{ .Chart.AppVersion | quote }}}}
808{{{{- end }}}}
809app.kubernetes.io/managed-by: {{{{ .Release.Service }}}}
810{{{{- end }}}}
811
812{{{{/*
813Selector labels.
814*/}}}}
815{{{{- define "{app}.selectorLabels" -}}}}
816app.kubernetes.io/name: {{{{ include "{app}.name" . }}}}
817app.kubernetes.io/instance: {{{{ .Release.Name }}}}
818{{{{- end }}}}
819
820{{{{/*
821Service account name.
822*/}}}}
823{{{{- define "{app}.serviceAccountName" -}}}}
824{{{{- if .Values.serviceAccount.create }}}}
825{{{{- default (include "{app}.fullname" .) .Values.serviceAccount.name }}}}
826{{{{- else }}}}
827{{{{- default "default" .Values.serviceAccount.name }}}}
828{{{{- end }}}}
829{{{{- end }}}}
830"#,
831    ));
832
833    // Secret name helpers -- one per secret group
834    for group in &c.secrets {
835        let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
836        out.push_str(&format!(
837            r#"
838{{{{/*
839{group} secret name -- use existing or generate from fullname.
840*/}}}}
841{{{{- define "{app}.{helper}" -}}}}
842{{{{- if .Values.{group}.existingSecret }}}}
843{{{{- .Values.{group}.existingSecret }}}}
844{{{{- else }}}}
845{{{{- printf "%s-{group}" (include "{app}.fullname" .) }}}}
846{{{{- end }}}}
847{{{{- end }}}}
848"#,
849            app = app,
850            group = group.group_name,
851            helper = helper_name,
852        ));
853    }
854
855    out
856}
857
858fn gen_deployment_yaml(c: &DeploymentContract) -> String {
859    let app = &c.app_name;
860    let mut out = String::with_capacity(4096);
861
862    // Header
863    out.push_str(&format!(
864        r#"apiVersion: apps/v1
865kind: Deployment
866metadata:
867  name: {{{{ include "{app}.fullname" . }}}}
868  labels:
869    {{{{- include "{app}.labels" . | nindent 4 }}}}
870spec:
871  {{{{- if not (or .Values.keda.enabled .Values.autoscaling.enabled) }}}}
872  replicas: {{{{ .Values.replicaCount }}}}
873  {{{{- end }}}}
874  selector:
875    matchLabels:
876      {{{{- include "{app}.selectorLabels" . | nindent 6 }}}}
877  template:
878    metadata:
879      annotations:
880        checksum/config: {{{{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}}}
881        {{{{- with .Values.podAnnotations }}}}
882        {{{{- toYaml . | nindent 8 }}}}
883        {{{{- end }}}}
884      labels:
885        {{{{- include "{app}.labels" . | nindent 8 }}}}
886        {{{{- with .Values.podLabels }}}}
887        {{{{- toYaml . | nindent 8 }}}}
888        {{{{- end }}}}
889    spec:
890      {{{{- with .Values.imagePullSecrets }}}}
891      imagePullSecrets:
892        {{{{- toYaml . | nindent 8 }}}}
893      {{{{- end }}}}
894      serviceAccountName: {{{{ include "{app}.serviceAccountName" . }}}}
895      containers:
896        - name: {{{{ .Chart.Name }}}}
897          image: "{{{{ .Values.image.repository }}}}:{{{{ .Values.image.tag | default .Chart.AppVersion }}}}"
898          imagePullPolicy: {{{{ .Values.image.pullPolicy }}}}
899"#,
900    ));
901
902    // Args
903    if !c.entrypoint_args.is_empty() {
904        out.push_str("          args:\n");
905        for arg in &c.entrypoint_args {
906            out.push_str(&format!("            - \"{arg}\"\n"));
907        }
908    }
909
910    // Ports
911    out.push_str(
912        "          ports:\n\
913         \x20           - name: metrics\n\
914         \x20             containerPort: {{ .Values.service.port }}\n\
915         \x20             protocol: TCP\n",
916    );
917    for port in &c.extra_ports {
918        out.push_str(&format!(
919            "            - name: {name}\n\
920             \x20             containerPort: {port}\n\
921             \x20             protocol: {proto}\n",
922            name = port.name,
923            port = port.port,
924            proto = port.protocol,
925        ));
926    }
927
928    // Env vars from secrets
929    if !c.secrets.is_empty() {
930        out.push_str("          env:\n");
931        for group in &c.secrets {
932            let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
933            out.push_str(&format!(
934                "            # {} credentials via Secret (figment env cascade overrides file config)\n",
935                group.group_name
936            ));
937            for env in &group.env_vars {
938                // See gen_secret_yaml -- hyphenated keys must use index form.
939                let key_lookup = safe_template_lookup(
940                    &format!(".Values.{}.secretKeys", group.group_name),
941                    &env.key_name,
942                );
943                out.push_str(&format!(
944                    "            - name: {env_var}\n\
945                     \x20             valueFrom:\n\
946                     \x20               secretKeyRef:\n\
947                     \x20                 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
948                     \x20                 key: {{{{ {key_lookup} }}}}\n",
949                    env_var = env.env_var,
950                    app = app,
951                    helper = helper_name,
952                ));
953            }
954        }
955    }
956
957    // Probes
958    out.push_str(&format!(
959        "          livenessProbe:\n\
960         \x20           httpGet:\n\
961         \x20             path: {liveness}\n\
962         \x20             port: metrics\n\
963         \x20           initialDelaySeconds: 10\n\
964         \x20           periodSeconds: 10\n\
965         \x20           failureThreshold: 3\n\
966         \x20         readinessProbe:\n\
967         \x20           httpGet:\n\
968         \x20             path: {readiness}\n\
969         \x20             port: metrics\n\
970         \x20           initialDelaySeconds: 5\n\
971         \x20           periodSeconds: 5\n\
972         \x20           failureThreshold: 2\n\
973         \x20         startupProbe:\n\
974         \x20           httpGet:\n\
975         \x20             path: {liveness}\n\
976         \x20             port: metrics\n\
977         \x20           failureThreshold: 30\n\
978         \x20           periodSeconds: 5\n",
979        liveness = c.health.liveness_path,
980        readiness = c.health.readiness_path,
981    ));
982
983    // Volume mounts
984    out.push_str(&format!(
985        "          volumeMounts:\n\
986         \x20           - name: config\n\
987         \x20             mountPath: {config_dir}\n\
988         \x20             readOnly: true\n",
989        config_dir = c.config_dir(),
990    ));
991
992    // Resources
993    out.push_str(
994        "          {{- with .Values.resources }}\n\
995         \x20         resources:\n\
996         \x20           {{- toYaml . | nindent 12 }}\n\
997         \x20         {{- end }}\n",
998    );
999
1000    // Volumes
1001    out.push_str(&format!(
1002        "      volumes:\n\
1003         \x20       - name: config\n\
1004         \x20         configMap:\n\
1005         \x20           name: {{{{ include \"{app}.fullname\" . }}}}-config\n",
1006    ));
1007
1008    // Node selector, affinity, tolerations
1009    out.push_str(
1010        "      {{- with .Values.nodeSelector }}\n\
1011         \x20     nodeSelector:\n\
1012         \x20       {{- toYaml . | nindent 8 }}\n\
1013         \x20     {{- end }}\n\
1014         \x20     {{- with .Values.affinity }}\n\
1015         \x20     affinity:\n\
1016         \x20       {{- toYaml . | nindent 8 }}\n\
1017         \x20     {{- end }}\n\
1018         \x20     {{- with .Values.tolerations }}\n\
1019         \x20     tolerations:\n\
1020         \x20       {{- toYaml . | nindent 8 }}\n\
1021         \x20     {{- end }}\n",
1022    );
1023
1024    out
1025}
1026
1027fn gen_service_yaml(c: &DeploymentContract) -> String {
1028    let app = &c.app_name;
1029    let mut out = format!(
1030        r#"apiVersion: v1
1031kind: Service
1032metadata:
1033  name: {{{{ include "{app}.fullname" . }}}}
1034  labels:
1035    {{{{- include "{app}.labels" . | nindent 4 }}}}
1036spec:
1037  type: {{{{ .Values.service.type }}}}
1038  ports:
1039    - port: {{{{ .Values.service.port }}}}
1040      targetPort: metrics
1041      protocol: TCP
1042      name: metrics
1043"#,
1044    );
1045
1046    // Extra ports
1047    for port in &c.extra_ports {
1048        out.push_str(&format!(
1049            "    - port: {port}\n\
1050             \x20     targetPort: {port}\n\
1051             \x20     protocol: {proto}\n\
1052             \x20     name: {name}\n",
1053            port = port.port,
1054            proto = port.protocol,
1055            name = port.name,
1056        ));
1057    }
1058
1059    out.push_str(&format!(
1060        "  selector:\n\
1061         \x20   {{{{- include \"{app}.selectorLabels\" . | nindent 4 }}}}\n",
1062    ));
1063
1064    out
1065}
1066
1067fn gen_serviceaccount_yaml(c: &DeploymentContract) -> String {
1068    let app = &c.app_name;
1069    format!(
1070        r#"{{{{- if .Values.serviceAccount.create -}}}}
1071apiVersion: v1
1072kind: ServiceAccount
1073metadata:
1074  name: {{{{ include "{app}.serviceAccountName" . }}}}
1075  labels:
1076    {{{{- include "{app}.labels" . | nindent 4 }}}}
1077  {{{{- with .Values.serviceAccount.annotations }}}}
1078  annotations:
1079    {{{{- toYaml . | nindent 4 }}}}
1080  {{{{- end }}}}
1081automountServiceAccountToken: false
1082{{{{- end }}}}
1083"#,
1084    )
1085}
1086
1087fn gen_configmap_yaml(c: &DeploymentContract) -> String {
1088    let app = &c.app_name;
1089
1090    let mut out = format!(
1091        r#"apiVersion: v1
1092kind: ConfigMap
1093metadata:
1094  name: {{{{ include "{app}.fullname" . }}}}-config
1095  labels:
1096    {{{{- include "{app}.labels" . | nindent 4 }}}}
1097data:
1098  {filename}: |
1099    {{{{- toYaml .Values.config | nindent 4 }}}}
1100"#,
1101        app = app,
1102        filename = c.config_filename(),
1103    );
1104
1105    let _ = &mut out; // keep borrow checker happy
1106    out
1107}
1108
1109fn gen_secret_yaml(c: &DeploymentContract) -> String {
1110    let app = &c.app_name;
1111    let mut out = String::new();
1112    let mut first = true;
1113
1114    for group in &c.secrets {
1115        if !first {
1116            out.push_str("---\n");
1117        }
1118        first = false;
1119
1120        let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
1121
1122        out.push_str(&format!(
1123            "{{{{- if not .Values.{group}.existingSecret }}}}\n\
1124             apiVersion: v1\n\
1125             kind: Secret\n\
1126             metadata:\n\
1127             \x20 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
1128             \x20 labels:\n\
1129             \x20   {{{{- include \"{app}.labels\" . | nindent 4 }}}}\n\
1130             type: Opaque\n\
1131             data:\n",
1132            group = group.group_name,
1133            app = app,
1134            helper = helper_name,
1135        ));
1136
1137        for env in &group.env_vars {
1138            // Hyphenated or otherwise non-Go-identifier-safe key names
1139            // require `(index .Values.x "key")` form instead of
1140            // `.Values.x.key` -- Go-template parser rejects hyphens etc.
1141            let key_lookup = safe_template_lookup(
1142                &format!(".Values.{}.secretKeys", group.group_name),
1143                &env.key_name,
1144            );
1145            let val_lookup =
1146                safe_template_lookup(&format!(".Values.{}", group.group_name), &env.key_name);
1147            out.push_str(&format!(
1148                "  {{{{ {key_lookup} }}}}: {{{{ {val_lookup} | b64enc | quote }}}}\n"
1149            ));
1150        }
1151
1152        out.push_str("{{- end }}\n");
1153    }
1154
1155    if c.secrets.is_empty() {
1156        out.push_str("# No secrets defined in deployment contract\n");
1157    }
1158
1159    out
1160}
1161
1162fn gen_hpa_yaml(c: &DeploymentContract) -> String {
1163    let app = &c.app_name;
1164    format!(
1165        r#"{{{{- if and .Values.autoscaling.enabled (not .Values.keda.enabled) }}}}
1166# Standard HPA fallback -- use when KEDA operator is not installed.
1167# Mutually exclusive with keda.enabled (KEDA creates its own HPA).
1168apiVersion: autoscaling/v2
1169kind: HorizontalPodAutoscaler
1170metadata:
1171  name: {{{{ include "{app}.fullname" . }}}}
1172  labels:
1173    {{{{- include "{app}.labels" . | nindent 4 }}}}
1174spec:
1175  scaleTargetRef:
1176    apiVersion: apps/v1
1177    kind: Deployment
1178    name: {{{{ include "{app}.fullname" . }}}}
1179  minReplicas: {{{{ .Values.autoscaling.minReplicas }}}}
1180  maxReplicas: {{{{ .Values.autoscaling.maxReplicas }}}}
1181  metrics:
1182    - type: Resource
1183      resource:
1184        name: cpu
1185        target:
1186          type: Utilization
1187          averageUtilization: {{{{ .Values.autoscaling.targetCPUUtilizationPercentage }}}}
1188{{{{- end }}}}
1189"#,
1190    )
1191}
1192
1193fn gen_keda_scaledobject_yaml(c: &DeploymentContract) -> String {
1194    let app = &c.app_name;
1195
1196    // Find kafka secret group for trigger auth reference
1197    let has_kafka_secret = c.secrets.iter().any(|g| g.group_name == "kafka");
1198
1199    let auth_ref = if has_kafka_secret {
1200        format!(
1201            "      authenticationRef:\n\
1202             \x20       name: {{{{ include \"{app}.fullname\" . }}}}-kafka-auth\n"
1203        )
1204    } else {
1205        String::new()
1206    };
1207
1208    format!(
1209        r#"{{{{- if .Values.keda.enabled }}}}
1210apiVersion: keda.sh/v1alpha1
1211kind: ScaledObject
1212metadata:
1213  name: {{{{ include "{app}.fullname" . }}}}
1214  labels:
1215    {{{{- include "{app}.labels" . | nindent 4 }}}}
1216spec:
1217  scaleTargetRef:
1218    name: {{{{ include "{app}.fullname" . }}}}
1219  minReplicaCount: {{{{ .Values.keda.minReplicaCount }}}}
1220  maxReplicaCount: {{{{ .Values.keda.maxReplicaCount }}}}
1221  pollingInterval: {{{{ .Values.keda.pollingInterval }}}}
1222  cooldownPeriod: {{{{ .Values.keda.cooldownPeriod }}}}
1223  triggers:
1224    # Kafka consumer group lag (primary scaler)
1225    - type: kafka
1226{auth_ref}      metadata:
1227        bootstrapServers: {{{{ .Values.config.kafka.brokers | quote }}}}
1228        consumerGroup: {{{{ .Values.keda.kafka.consumerGroup | default .Values.config.kafka.group_id | quote }}}}
1229        {{{{- /* `default (index X 0)` would eagerly evaluate `index nil 0` and fail
1230            lint when no topics are set. Use explicit conditional instead. */}}}}
1231        {{{{- if .Values.keda.kafka.topic }}}}
1232        topic: {{{{ .Values.keda.kafka.topic | quote }}}}
1233        {{{{- else if .Values.config.kafka.topics }}}}
1234        topic: {{{{ (index .Values.config.kafka.topics 0) | quote }}}}
1235        {{{{- else }}}}
1236        topic: ""
1237        {{{{- end }}}}
1238        lagThreshold: {{{{ .Values.keda.kafka.lagThreshold | quote }}}}
1239        activationLagThreshold: {{{{ .Values.keda.kafka.activationLagThreshold | quote }}}}
1240        saslType: scram_sha512
1241        tls: disable
1242    {{{{- if .Values.keda.cpu.enabled }}}}
1243    # CPU utilisation (secondary scaler)
1244    - type: cpu
1245      metricType: Utilization
1246      metadata:
1247        value: {{{{ .Values.keda.cpu.threshold | quote }}}}
1248    {{{{- end }}}}
1249{{{{- end }}}}
1250"#,
1251    )
1252}
1253
1254fn gen_keda_triggerauth_yaml(c: &DeploymentContract) -> String {
1255    let app = &c.app_name;
1256
1257    // Find the kafka secret group
1258    let kafka_group = c.secrets.iter().find(|g| g.group_name == "kafka");
1259
1260    if kafka_group.is_none() {
1261        return "# No kafka secret group -- KEDA TriggerAuthentication not generated\n".to_string();
1262    }
1263
1264    let helper_name = format!("{}SecretName", to_camel_suffix("kafka"));
1265
1266    format!(
1267        r#"{{{{- if .Values.keda.enabled }}}}
1268apiVersion: keda.sh/v1alpha1
1269kind: TriggerAuthentication
1270metadata:
1271  name: {{{{ include "{app}.fullname" . }}}}-kafka-auth
1272  labels:
1273    {{{{- include "{app}.labels" . | nindent 4 }}}}
1274spec:
1275  secretTargetRef:
1276    - parameter: sasl
1277      name: {{{{ include "{app}.{helper_name}" . }}}}
1278      key: {{{{ .Values.kafka.secretKeys.username }}}}
1279    - parameter: password
1280      name: {{{{ include "{app}.{helper_name}" . }}}}
1281      key: {{{{ .Values.kafka.secretKeys.password }}}}
1282{{{{- end }}}}
1283"#,
1284    )
1285}
1286
1287fn gen_notes_txt(c: &DeploymentContract) -> String {
1288    let app = &c.app_name;
1289
1290    format!(
1291        r#"{app} has been deployed.
1292
12931. Get the metrics/health endpoint:
1294   kubectl port-forward svc/{{{{ include "{app}.fullname" . }}}} {{{{ .Values.service.port }}}}:{{{{ .Values.service.port }}}}
1295   curl http://localhost:{{{{ .Values.service.port }}}}{liveness}
1296   curl http://localhost:{{{{ .Values.service.port }}}}{metrics}
1297
1298{{{{- if .Values.keda.enabled }}}}
1299
13002. Check KEDA autoscaling status:
1301   kubectl get scaledobject {{{{ include "{app}.fullname" . }}}}
1302   kubectl get hpa
1303{{{{- end }}}}
1304
13053. View logs:
1306   kubectl logs -l app.kubernetes.io/name={{{{ include "{app}.name" . }}}} -f
1307"#,
1308        app = app,
1309        liveness = c.health.liveness_path,
1310        metrics = c.health.metrics_path,
1311    )
1312}
1313
1314// ============================================================================
1315// ArgoCD Application
1316// ============================================================================
1317
1318/// Configuration for ArgoCD `Application` generation.
1319///
1320/// All fields have sensible defaults. The Helm chart that the Application
1321/// points at is the one [`generate_chart`] writes to a repo path.
1322#[derive(Debug, Clone)]
1323pub struct ArgocdConfig {
1324    /// ArgoCD namespace (where the Application CR lives). Default: `argocd`.
1325    pub argocd_namespace: String,
1326    /// Destination namespace for the deployed app. Default: `dfe`.
1327    pub dest_namespace: String,
1328    /// Destination cluster (`server` field). Default: `https://kubernetes.default.svc`.
1329    pub dest_server: String,
1330    /// Source git repo URL (where the Helm chart lives). Required.
1331    pub repo_url: String,
1332    /// Source git revision (branch/tag). Default: `main`.
1333    pub target_revision: String,
1334    /// Path within the repo to the Helm chart. Default: `chart`.
1335    pub chart_path: String,
1336    /// ArgoCD project. Default: `default`.
1337    pub project: String,
1338    /// Sync wave (lower runs first). Default: [`crate::deployment::WAVE_APPS`].
1339    pub sync_wave: i32,
1340    /// Additional `ignoreDifferences` entries appended to the canonical
1341    /// defaults (HPA replicas, ClusterIP, webhook caBundle). Each entry
1342    /// is a raw YAML fragment matching the ArgoCD `ignoreDifferences` item
1343    /// shape. The fragment must start with `- group:` and use two-space
1344    /// indentation internally (the generator indents each line by four
1345    /// spaces to nest under `ignoreDifferences:`).
1346    ///
1347    /// Example:
1348    /// ```text
1349    /// "- group: apps\n  kind: Deployment\n  jsonPointers:\n    - /spec/template/spec/containers/0/image"
1350    /// ```
1351    ///
1352    /// Defaults to empty (no extra entries beyond the canonical defaults).
1353    pub extra_ignore_differences: Vec<String>,
1354}
1355
1356impl Default for ArgocdConfig {
1357    fn default() -> Self {
1358        Self {
1359            argocd_namespace: "argocd".into(),
1360            dest_namespace: "dfe".into(),
1361            dest_server: "https://kubernetes.default.svc".into(),
1362            repo_url: String::new(),
1363            target_revision: "main".into(),
1364            chart_path: "chart".into(),
1365            project: "default".into(),
1366            sync_wave: crate::deployment::WAVE_APPS,
1367            extra_ignore_differences: Vec::new(),
1368        }
1369    }
1370}
1371
1372/// Generate an ArgoCD `Application` custom-resource YAML from the deployment
1373/// contract.
1374///
1375/// The CR points at a Helm chart in a git repo (typically the chart that
1376/// [`generate_chart`] produces). Apply with:
1377///
1378/// ```bash
1379/// kubectl apply -n argocd -f application.yaml
1380/// ```
1381///
1382/// # Example
1383///
1384/// ```rust,no_run
1385/// use hyperi_rustlib::deployment::{ArgocdConfig, generate_argocd_application};
1386/// # use hyperi_rustlib::deployment::DeploymentContract;
1387/// # let contract: DeploymentContract = unimplemented!();
1388/// let argo = ArgocdConfig {
1389///     repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
1390///     ..Default::default()
1391/// };
1392/// let yaml = generate_argocd_application(&contract, &argo, None);
1393/// ```
1394#[must_use]
1395pub fn generate_argocd_application(
1396    contract: &DeploymentContract,
1397    argo: &ArgocdConfig,
1398    identity: Option<&crate::deployment::ContractIdentity>,
1399) -> String {
1400    // Contract Identity Annotation Scheme v1 -- three extra annotations
1401    // alongside the existing sync-wave entry. Indented to match the
1402    // 4-space `metadata.annotations:` block below.
1403    let identity_block = identity
1404        .map(|id| format!("\n{ann}", ann = id.as_yaml_annotations(4)))
1405        .unwrap_or_default();
1406
1407    // Build the extras block: each entry is a raw YAML fragment starting with
1408    // `- group: ...`. Indent every line by 4 spaces to nest under
1409    // `ignoreDifferences:`.
1410    let extras_block = if argo.extra_ignore_differences.is_empty() {
1411        String::new()
1412    } else {
1413        let mut buf = String::new();
1414        for entry in &argo.extra_ignore_differences {
1415            for line in entry.lines() {
1416                buf.push_str("    ");
1417                buf.push_str(line);
1418                buf.push('\n');
1419            }
1420        }
1421        buf
1422    };
1423
1424    format!(
1425        r#"# AUTOGENERATED -- do not edit by hand.
1426# Generated by hyperi-rustlib::deployment::generate_argocd_application()
1427# Schema version: {schema_version}
1428# Source contract: {app_name}::deployment::contract()
1429# Regenerate with: `{binary} emit-argocd > application.yaml`
1430apiVersion: argoproj.io/v1alpha1
1431kind: Application
1432metadata:
1433  name: {app_name}
1434  namespace: {argocd_namespace}
1435  annotations:
1436    argocd.argoproj.io/sync-wave: "{sync_wave}"{identity_block}
1437  finalizers:
1438    - resources-finalizer.argocd.argoproj.io
1439spec:
1440  project: {project}
1441
1442  source:
1443    repoURL: {repo_url}
1444    targetRevision: {target_revision}
1445    path: {chart_path}
1446    helm:
1447      releaseName: {app_name}
1448
1449  destination:
1450    server: {dest_server}
1451    namespace: {dest_namespace}
1452
1453  syncPolicy:
1454    automated:
1455      prune: true
1456      selfHeal: true
1457      allowEmpty: false
1458    syncOptions:
1459      - CreateNamespace=true
1460      - PrunePropagationPolicy=foreground
1461      - PruneLast=true
1462      - ServerSideApply=true
1463    retry:
1464      limit: 5
1465      backoff:
1466        duration: 5s
1467        factor: 2
1468        maxDuration: 3m
1469
1470  ignoreDifferences:
1471    - group: apps
1472      kind: Deployment
1473      jsonPointers:
1474        - /spec/replicas
1475    - group: ""
1476      kind: Service
1477      jsonPointers:
1478        - /spec/clusterIP
1479        - /spec/clusterIPs
1480    - group: admissionregistration.k8s.io
1481      kind: ValidatingWebhookConfiguration
1482      jqPathExpressions:
1483        - .webhooks[].clientConfig.caBundle
1484{extras_block}"#,
1485        schema_version = contract.schema_version,
1486        app_name = contract.app_name,
1487        binary = contract.binary(),
1488        argocd_namespace = argo.argocd_namespace,
1489        sync_wave = argo.sync_wave,
1490        project = argo.project,
1491        repo_url = argo.repo_url,
1492        target_revision = argo.target_revision,
1493        chart_path = argo.chart_path,
1494        dest_server = argo.dest_server,
1495        dest_namespace = argo.dest_namespace,
1496        extras_block = extras_block,
1497        identity_block = identity_block,
1498    )
1499}
1500
1501// ============================================================================
1502// Helpers
1503// ============================================================================
1504
1505/// Convert a group name to camelCase suffix (e.g., "kafka" -> "kafka", "click_house" -> "clickHouse").
1506fn to_camel_suffix(name: &str) -> String {
1507    let mut result = String::new();
1508    let mut capitalize_next = false;
1509
1510    for ch in name.chars() {
1511        if ch == '_' || ch == '-' {
1512            capitalize_next = true;
1513        } else if capitalize_next {
1514            result.push(ch.to_ascii_uppercase());
1515            capitalize_next = false;
1516        } else {
1517            result.push(ch);
1518        }
1519    }
1520
1521    result
1522}
1523
1524/// True if `s` is a valid Go-template identifier (matches
1525/// `[A-Za-z_][A-Za-z0-9_]*`). Go templates accept the dot-walked
1526/// `.foo.bar` syntax only for keys matching this shape; any other
1527/// key (hyphens, dots, digit-leading, etc.) must use the
1528/// `(index .foo "key")` form or `helm lint` fails with
1529/// `bad character U+002D '-'` (and similar).
1530fn is_go_identifier(s: &str) -> bool {
1531    let mut chars = s.chars();
1532    match chars.next() {
1533        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1534        _ => return false,
1535    }
1536    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1537}
1538
1539/// Render a Go-template lookup expression that's safe for any key.
1540///
1541/// `base` must be a dot-prefixed path that itself only contains
1542/// Go-safe identifiers (e.g. `.Values.auth.secretKeys`). `key` may
1543/// contain anything; the function picks the dot-walked form when
1544/// it's safe and the `(index ... "key")` form otherwise.
1545///
1546/// Examples:
1547///   - `safe_template_lookup(".Values.auth", "username")`
1548///     → `.Values.auth.username`
1549///   - `safe_template_lookup(".Values.auth", "bearer-tokens")`
1550///     → `(index .Values.auth "bearer-tokens")`
1551fn safe_template_lookup(base: &str, key: &str) -> String {
1552    if is_go_identifier(key) {
1553        format!("{base}.{key}")
1554    } else {
1555        format!("(index {base} \"{key}\")")
1556    }
1557}
1558
1559fn write_file(path: impl AsRef<Path>, content: &str) -> Result<(), DeploymentError> {
1560    let path = path.as_ref();
1561    std::fs::write(path, content).map_err(|e| DeploymentError::WriteFile {
1562        path: path.display().to_string(),
1563        source: e,
1564    })
1565}
1566
1567// ============================================================================
1568// Tests
1569// ============================================================================
1570
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574    use crate::deployment::contract::{
1575        OciLabels, PortContract, SecretEnvContract, SecretGroupContract,
1576    };
1577    use crate::deployment::keda::KedaContract;
1578    use crate::deployment::native_deps::NativeDepsContract;
1579
1580    fn test_contract() -> DeploymentContract {
1581        DeploymentContract {
1582            app_name: "dfe-loader".into(),
1583            binary_name: "dfe-loader".into(),
1584            description: "High-performance Kafka to ClickHouse data loader".into(),
1585            metrics_port: 9090,
1586            health: super::super::HealthContract::default(),
1587            env_prefix: "DFE_LOADER".into(),
1588            metric_prefix: "loader".into(),
1589            config_mount_path: "/etc/dfe/loader.yaml".into(),
1590            image_registry: "ghcr.io/hyperi-io".into(),
1591            extra_ports: vec![],
1592            entrypoint_args: vec!["--config".into(), "/etc/dfe/loader.yaml".into()],
1593            secrets: vec![
1594                SecretGroupContract {
1595                    group_name: "kafka".into(),
1596                    env_vars: vec![
1597                        SecretEnvContract {
1598                            env_var: "DFE_LOADER__KAFKA__USERNAME".into(),
1599                            key_name: "username".into(),
1600                            secret_key: "kafka-username".into(),
1601                        },
1602                        SecretEnvContract {
1603                            env_var: "DFE_LOADER__KAFKA__PASSWORD".into(),
1604                            key_name: "password".into(),
1605                            secret_key: "kafka-password".into(),
1606                        },
1607                    ],
1608                },
1609                SecretGroupContract {
1610                    group_name: "clickhouse".into(),
1611                    env_vars: vec![SecretEnvContract {
1612                        env_var: "DFE_LOADER__CLICKHOUSE__PASSWORD".into(),
1613                        key_name: "password".into(),
1614                        secret_key: "clickhouse-password".into(),
1615                    }],
1616                },
1617            ],
1618            default_config: None,
1619            depends_on: vec!["kafka".into(), "clickhouse".into()],
1620            keda: Some(KedaContract::default()),
1621            base_image: "ubuntu:24.04".into(),
1622            native_deps: NativeDepsContract::default(),
1623            image_profile: ImageProfile::default(),
1624            schema_version: 2,
1625            oci_labels: OciLabels::default(),
1626        }
1627    }
1628
1629    #[test]
1630    fn test_generate_dockerfile() {
1631        let contract = test_contract();
1632        let dockerfile = generate_dockerfile(&contract, None);
1633
1634        assert!(dockerfile.contains("FROM ubuntu:24.04"));
1635        assert!(dockerfile.contains("COPY dfe-loader /usr/local/bin/dfe-loader"));
1636        assert!(dockerfile.contains("EXPOSE 9090"));
1637        assert!(dockerfile.contains("localhost:9090/healthz"));
1638        assert!(dockerfile.contains("ENTRYPOINT [\"dfe-loader\"]"));
1639        assert!(dockerfile.contains("CMD [\"--config\", \"/etc/dfe/loader.yaml\"]"));
1640    }
1641
1642    #[test]
1643    fn test_generate_dockerfile_with_native_deps() {
1644        let mut contract = test_contract();
1645        contract.native_deps = NativeDepsContract::for_rustlib_features(
1646            &["transport-kafka", "spool", "tiered-sink"],
1647            "ubuntu:24.04",
1648        );
1649
1650        let dockerfile = generate_dockerfile(&contract, None);
1651
1652        // Should contain Confluent APT repo setup
1653        assert!(dockerfile.contains("packages.confluent.io"));
1654        assert!(dockerfile.contains("confluent-clients.gpg"));
1655        // Should contain runtime packages
1656        assert!(dockerfile.contains("librdkafka1"));
1657        assert!(dockerfile.contains("libssl3"));
1658        assert!(dockerfile.contains("libzstd1"));
1659        // Should include gnupg for key import
1660        assert!(dockerfile.contains("gnupg"));
1661    }
1662
1663    #[test]
1664    fn test_generate_dockerfile_no_native_deps() {
1665        let mut contract = test_contract();
1666        contract.native_deps = NativeDepsContract::for_rustlib_features(
1667            &["cli", "deployment", "logger"],
1668            "ubuntu:24.04",
1669        );
1670
1671        let dockerfile = generate_dockerfile(&contract, None);
1672
1673        // No Confluent repo, no runtime packages
1674        assert!(!dockerfile.contains("confluent"));
1675        assert!(!dockerfile.contains("librdkafka1"));
1676        assert!(!dockerfile.contains("gnupg"));
1677    }
1678
1679    #[test]
1680    fn test_generate_dockerfile_bookworm_codename() {
1681        let mut contract = test_contract();
1682        contract.base_image = "debian:bookworm-slim".into();
1683        contract.native_deps =
1684            NativeDepsContract::for_rustlib_features(&["transport-kafka"], "debian:bookworm-slim");
1685
1686        let dockerfile = generate_dockerfile(&contract, None);
1687        assert!(dockerfile.contains("bookworm main"));
1688    }
1689
1690    #[test]
1691    fn test_generate_dockerfile_production_profile() {
1692        let contract = test_contract();
1693        let dockerfile = generate_dockerfile(&contract, None);
1694
1695        assert!(dockerfile.contains("Purpose:   production container image"));
1696        assert!(dockerfile.contains("io.hyperi.profile=\"production\""));
1697        assert!(!dockerfile.contains("strace"));
1698        assert!(!dockerfile.contains("tcpdump"));
1699    }
1700
1701    #[test]
1702    fn test_generate_dockerfile_dev_profile() {
1703        let contract = test_contract().with_dev_profile();
1704        let dockerfile = generate_dockerfile(&contract, None);
1705
1706        assert!(dockerfile.contains("Purpose:   development container image"));
1707        assert!(dockerfile.contains("io.hyperi.profile=\"development\""));
1708        assert!(dockerfile.contains("strace"));
1709        assert!(dockerfile.contains("tcpdump"));
1710        assert!(dockerfile.contains("procps"));
1711        assert!(dockerfile.contains("bash"));
1712        assert!(dockerfile.contains("jq"));
1713    }
1714
1715    #[test]
1716    fn test_generate_dockerfile_dev_with_native_deps() {
1717        let mut contract = test_contract();
1718        contract.native_deps =
1719            NativeDepsContract::for_rustlib_features(&["transport-kafka", "spool"], "ubuntu:24.04");
1720        let dev = contract.with_dev_profile();
1721        let dockerfile = generate_dockerfile(&dev, None);
1722
1723        // Dev tools present alongside native deps
1724        assert!(dockerfile.contains("strace"));
1725        assert!(dockerfile.contains("librdkafka1"));
1726        assert!(dockerfile.contains("libzstd1"));
1727        assert!(dockerfile.contains("io.hyperi.profile=\"development\""));
1728    }
1729
1730    #[test]
1731    fn test_with_dev_profile_preserves_contract() {
1732        let contract = test_contract();
1733        let dev = contract.with_dev_profile();
1734
1735        assert_eq!(dev.app_name, contract.app_name);
1736        assert_eq!(dev.metrics_port, contract.metrics_port);
1737        assert_eq!(dev.image_profile, ImageProfile::Development);
1738        assert_eq!(contract.image_profile, ImageProfile::Production);
1739    }
1740
1741    #[test]
1742    fn test_generate_dockerfile_extra_ports() {
1743        let mut contract = test_contract();
1744        contract.extra_ports = vec![PortContract {
1745            name: "http".into(),
1746            port: 8080,
1747            protocol: "TCP".into(),
1748        }];
1749
1750        let dockerfile = generate_dockerfile(&contract, None);
1751        assert!(dockerfile.contains("EXPOSE 9090 8080"));
1752    }
1753
1754    #[test]
1755    fn test_generate_compose_fragment() {
1756        let contract = test_contract();
1757        let compose = generate_compose_fragment(&contract);
1758
1759        assert!(compose.contains("dfe-loader:"));
1760        assert!(compose.contains("ghcr.io/hyperi-io/dfe-loader"));
1761        assert!(compose.contains("kafka:"));
1762        assert!(compose.contains("clickhouse:"));
1763        assert!(compose.contains("condition: service_healthy"));
1764        assert!(compose.contains("\"9090:9090\""));
1765        assert!(compose.contains("loader.yaml:/etc/dfe/loader.yaml:ro"));
1766    }
1767
1768    #[test]
1769    fn test_generate_chart() {
1770        let contract = test_contract();
1771        let dir = tempfile::tempdir().unwrap();
1772
1773        generate_chart(&contract, dir.path(), None).unwrap();
1774
1775        // Verify files exist
1776        assert!(dir.path().join("Chart.yaml").exists());
1777        assert!(dir.path().join("values.yaml").exists());
1778        assert!(dir.path().join("templates/_helpers.tpl").exists());
1779        assert!(dir.path().join("templates/deployment.yaml").exists());
1780        assert!(dir.path().join("templates/service.yaml").exists());
1781        assert!(dir.path().join("templates/serviceaccount.yaml").exists());
1782        assert!(dir.path().join("templates/configmap.yaml").exists());
1783        assert!(dir.path().join("templates/secret.yaml").exists());
1784        assert!(dir.path().join("templates/hpa.yaml").exists());
1785        assert!(dir.path().join("templates/keda-scaledobject.yaml").exists());
1786        assert!(dir.path().join("templates/keda-triggerauth.yaml").exists());
1787        assert!(dir.path().join("templates/NOTES.txt").exists());
1788    }
1789
1790    #[test]
1791    fn test_chart_yaml_content() {
1792        let contract = test_contract();
1793        let dir = tempfile::tempdir().unwrap();
1794        generate_chart(&contract, dir.path(), None).unwrap();
1795
1796        let content = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
1797        assert!(content.contains("name: dfe-loader"));
1798        assert!(content.contains("description: High-performance Kafka to ClickHouse data loader"));
1799    }
1800
1801    #[test]
1802    fn test_values_yaml_content() {
1803        let contract = test_contract();
1804        let dir = tempfile::tempdir().unwrap();
1805        generate_chart(&contract, dir.path(), None).unwrap();
1806
1807        let content = std::fs::read_to_string(dir.path().join("values.yaml")).unwrap();
1808        assert!(content.contains("port: 9090"));
1809        assert!(content.contains("prometheus.io/port: \"9090\""));
1810        assert!(content.contains("prometheus.io/path: \"/metrics\""));
1811        assert!(content.contains("lagThreshold: \"1000\""));
1812        assert!(content.contains("kafka-username"));
1813        assert!(content.contains("kafka-password"));
1814        assert!(content.contains("clickhouse-password"));
1815    }
1816
1817    #[test]
1818    fn test_helpers_contain_secret_helpers() {
1819        let contract = test_contract();
1820        let dir = tempfile::tempdir().unwrap();
1821        generate_chart(&contract, dir.path(), None).unwrap();
1822
1823        let content = std::fs::read_to_string(dir.path().join("templates/_helpers.tpl")).unwrap();
1824        assert!(content.contains("kafkaSecretName"));
1825        assert!(content.contains("clickhouseSecretName"));
1826    }
1827
1828    #[test]
1829    fn test_deployment_contains_env_vars() {
1830        let contract = test_contract();
1831        let dir = tempfile::tempdir().unwrap();
1832        generate_chart(&contract, dir.path(), None).unwrap();
1833
1834        let content =
1835            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
1836        assert!(content.contains("DFE_LOADER__KAFKA__USERNAME"));
1837        assert!(content.contains("DFE_LOADER__KAFKA__PASSWORD"));
1838        assert!(content.contains("DFE_LOADER__CLICKHOUSE__PASSWORD"));
1839        assert!(content.contains("path: /healthz"));
1840        assert!(content.contains("path: /readyz"));
1841        assert!(content.contains("/etc/dfe"));
1842    }
1843
1844    #[test]
1845    fn test_is_go_identifier() {
1846        // Valid Go identifiers
1847        assert!(is_go_identifier("foo"));
1848        assert!(is_go_identifier("FOO"));
1849        assert!(is_go_identifier("foo_bar"));
1850        assert!(is_go_identifier("_underscore_start"));
1851        assert!(is_go_identifier("foo123"));
1852        assert!(is_go_identifier("a"));
1853
1854        // Invalid -- would break Go templates
1855        assert!(!is_go_identifier("bearer-tokens")); // hyphen
1856        assert!(!is_go_identifier("foo.bar")); // dot
1857        assert!(!is_go_identifier("123foo")); // digit-leading
1858        assert!(!is_go_identifier("")); // empty
1859        assert!(!is_go_identifier("foo bar")); // space
1860        assert!(!is_go_identifier("foo:bar")); // colon
1861    }
1862
1863    #[test]
1864    fn test_safe_template_lookup_chooses_form() {
1865        assert_eq!(
1866            safe_template_lookup(".Values.auth", "username"),
1867            ".Values.auth.username"
1868        );
1869        assert_eq!(
1870            safe_template_lookup(".Values.auth", "bearer-tokens"),
1871            "(index .Values.auth \"bearer-tokens\")"
1872        );
1873        assert_eq!(
1874            safe_template_lookup(".Values.kafka.secretKeys", "kafka-username"),
1875            "(index .Values.kafka.secretKeys \"kafka-username\")"
1876        );
1877    }
1878
1879    /// Regression for the dfe-receiver canary 2026-05-25 finding:
1880    /// keda-scaledobject.yaml previously used
1881    /// `default (index .Values.config.kafka.topics 0)` which `helm lint`
1882    /// rejects with `error calling index: index of untyped nil` because
1883    /// Sprig's `default` evaluates both operands. The render must now
1884    /// use a conditional `if/else if/else` block instead.
1885    #[test]
1886    fn test_keda_scaledobject_topic_lookup_is_lint_safe() {
1887        let contract = test_contract();
1888        let dir = tempfile::tempdir().unwrap();
1889        generate_chart(&contract, dir.path(), None).unwrap();
1890
1891        let keda_yaml =
1892            std::fs::read_to_string(dir.path().join("templates/keda-scaledobject.yaml")).unwrap();
1893
1894        // Old broken form must not appear
1895        assert!(
1896            !keda_yaml.contains(
1897                ".Values.keda.kafka.topic | default (index .Values.config.kafka.topics 0)"
1898            ),
1899            "keda-scaledobject.yaml still uses the eagerly-evaluated `default (index ...)` form:\n{keda_yaml}"
1900        );
1901
1902        // New conditional form must appear
1903        assert!(
1904            keda_yaml.contains("if .Values.keda.kafka.topic"),
1905            "keda-scaledobject.yaml missing if/else guard for topic lookup:\n{keda_yaml}"
1906        );
1907        assert!(
1908            keda_yaml.contains("else if .Values.config.kafka.topics"),
1909            "keda-scaledobject.yaml missing fallback branch for config.kafka.topics:\n{keda_yaml}"
1910        );
1911    }
1912
1913    /// Regression for the dfe-receiver canary 2026-05-25 finding:
1914    /// secret.yaml previously emitted `.Values.x.bearer-tokens` which
1915    /// Go templates reject ("bad character U+002D '-'"). The render
1916    /// must now use the `(index .Values.x "bearer-tokens")` form.
1917    #[test]
1918    fn test_secret_yaml_handles_hyphenated_key_names() {
1919        let mut contract = test_contract();
1920        // dfe-receiver-style hyphenated key_name (token group)
1921        contract.secrets.push(SecretGroupContract {
1922            group_name: "auth".into(),
1923            env_vars: vec![SecretEnvContract {
1924                env_var: "DFE_RECEIVER__AUTH__BEARER_TOKENS".into(),
1925                key_name: "bearer-tokens".into(),
1926                secret_key: "bearer-tokens".into(),
1927            }],
1928        });
1929
1930        let dir = tempfile::tempdir().unwrap();
1931        generate_chart(&contract, dir.path(), None).unwrap();
1932
1933        let secret_yaml =
1934            std::fs::read_to_string(dir.path().join("templates/secret.yaml")).unwrap();
1935        let deployment_yaml =
1936            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
1937
1938        // Old broken form must not appear anywhere
1939        assert!(
1940            !secret_yaml.contains(".Values.auth.bearer-tokens"),
1941            "secret.yaml still uses broken dot-walked form for hyphenated key:\n{secret_yaml}"
1942        );
1943        assert!(
1944            !secret_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
1945            "secret.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{secret_yaml}"
1946        );
1947        assert!(
1948            !deployment_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
1949            "deployment.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{deployment_yaml}"
1950        );
1951
1952        // Safe index form must appear
1953        assert!(
1954            secret_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
1955            "secret.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{secret_yaml}"
1956        );
1957        assert!(
1958            secret_yaml.contains("(index .Values.auth \"bearer-tokens\")"),
1959            "secret.yaml missing index-form lookup for value bearer-tokens:\n{secret_yaml}"
1960        );
1961        assert!(
1962            deployment_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
1963            "deployment.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{deployment_yaml}"
1964        );
1965
1966        // Sanity: Go-safe keys (e.g. existing kafka.username) still use dot form
1967        assert!(
1968            secret_yaml.contains(".Values.kafka.secretKeys.username"),
1969            "Go-safe key 'username' should still use dot-walked form:\n{secret_yaml}"
1970        );
1971    }
1972
1973    #[test]
1974    fn test_generate_argocd_application_default() {
1975        let contract = test_contract();
1976        let argo = ArgocdConfig {
1977            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
1978            ..Default::default()
1979        };
1980        let yaml = generate_argocd_application(&contract, &argo, None);
1981
1982        assert!(yaml.contains("apiVersion: argoproj.io/v1alpha1"));
1983        assert!(yaml.contains("kind: Application"));
1984        assert!(yaml.contains("name: dfe-loader"));
1985        assert!(yaml.contains("namespace: argocd"));
1986        assert!(yaml.contains("repoURL: https://github.com/hyperi-io/dfe-loader"));
1987        assert!(yaml.contains("targetRevision: main"));
1988        assert!(yaml.contains("path: chart"));
1989        assert!(yaml.contains("CreateNamespace=true"));
1990        assert!(yaml.contains("Schema version: "));
1991    }
1992
1993    #[test]
1994    fn test_generate_argocd_custom_namespace_and_path() {
1995        let contract = test_contract();
1996        let argo = ArgocdConfig {
1997            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
1998            dest_namespace: "production".into(),
1999            chart_path: "deploy/chart".into(),
2000            target_revision: "v1.0.0".into(),
2001            sync_wave: 5,
2002            ..Default::default()
2003        };
2004        let yaml = generate_argocd_application(&contract, &argo, None);
2005        assert!(yaml.contains("namespace: production"));
2006        assert!(yaml.contains("path: deploy/chart"));
2007        assert!(yaml.contains("targetRevision: v1.0.0"));
2008        assert!(yaml.contains("sync-wave: \"5\""));
2009    }
2010
2011    #[test]
2012    fn argocd_config_default_uses_wave_apps() {
2013        let cfg = ArgocdConfig::default();
2014        assert_eq!(cfg.sync_wave, crate::deployment::WAVE_APPS);
2015    }
2016
2017    #[test]
2018    fn argocd_config_default_has_no_extra_ignore_differences() {
2019        let cfg = ArgocdConfig::default();
2020        assert!(cfg.extra_ignore_differences.is_empty());
2021    }
2022
2023    #[test]
2024    fn generate_argocd_application_emits_default_ignore_differences() {
2025        let contract = test_contract();
2026        let argo = ArgocdConfig {
2027            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
2028            ..Default::default()
2029        };
2030        let yaml = generate_argocd_application(&contract, &argo, None);
2031        assert!(yaml.contains("ignoreDifferences:"));
2032        assert!(yaml.contains("/spec/replicas"));
2033        assert!(yaml.contains("/spec/clusterIP"));
2034        assert!(yaml.contains(".webhooks[].clientConfig.caBundle"));
2035    }
2036
2037    #[test]
2038    fn generate_argocd_application_appends_extra_ignore_differences() {
2039        let contract = test_contract();
2040        let argo = ArgocdConfig {
2041            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
2042            extra_ignore_differences: vec![
2043                "- group: apps\n  kind: Deployment\n  jsonPointers:\n    - /spec/template/spec/containers/0/image".into(),
2044            ],
2045            ..Default::default()
2046        };
2047        let yaml = generate_argocd_application(&contract, &argo, None);
2048        assert!(yaml.contains("/spec/template/spec/containers/0/image"));
2049    }
2050
2051    #[test]
2052    fn generate_argocd_application_sync_wave_annotation_uses_config_value() {
2053        let contract = test_contract();
2054        let argo = ArgocdConfig {
2055            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
2056            sync_wave: crate::deployment::WAVE_TOPICS,
2057            ..Default::default()
2058        };
2059        let yaml = generate_argocd_application(&contract, &argo, None);
2060        assert!(yaml.contains(r#"argocd.argoproj.io/sync-wave: "-5""#));
2061    }
2062
2063    #[test]
2064    fn test_no_keda_files_when_disabled() {
2065        let mut contract = test_contract();
2066        contract.keda = None;
2067
2068        let dir = tempfile::tempdir().unwrap();
2069        generate_chart(&contract, dir.path(), None).unwrap();
2070
2071        assert!(!dir.path().join("templates/keda-scaledobject.yaml").exists());
2072        assert!(!dir.path().join("templates/keda-triggerauth.yaml").exists());
2073    }
2074
2075    #[test]
2076    fn test_to_camel_suffix() {
2077        assert_eq!(to_camel_suffix("kafka"), "kafka");
2078        assert_eq!(to_camel_suffix("clickhouse"), "clickhouse");
2079        assert_eq!(to_camel_suffix("click_house"), "clickHouse");
2080        assert_eq!(to_camel_suffix("my-service"), "myService");
2081    }
2082
2083    // ============================================================================
2084    // Contract Identity Annotation Scheme v1 -- end-to-end wiring tests.
2085    // The unit tests for ContractIdentity itself live in
2086    // src/deployment/contract_identity.rs; these verify the three
2087    // generators each emit the three keys in the right surface.
2088    // ============================================================================
2089
2090    fn test_identity() -> crate::deployment::ContractIdentity {
2091        crate::deployment::ContractIdentity::new(
2092            "0123456789abcdef0123456789abcdef01234567",
2093            "ghcr.io/hyperi-io/dfe-loader:v2.7.2",
2094        )
2095        .expect("test fixture must be valid")
2096    }
2097
2098    #[test]
2099    fn dockerfile_omits_identity_block_when_none() {
2100        let dockerfile = generate_dockerfile(&test_contract(), None);
2101        assert!(!dockerfile.contains("io.hyperi.contract"));
2102    }
2103
2104    #[test]
2105    fn dockerfile_emits_three_identity_labels_when_some() {
2106        let id = test_identity();
2107        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
2108        assert!(dockerfile.contains("LABEL io.hyperi.contract.version=\"v1\""));
2109        assert!(dockerfile.contains(
2110            "LABEL io.hyperi.contract.source-commit=\"0123456789abcdef0123456789abcdef01234567\""
2111        ));
2112        assert!(dockerfile.contains(
2113            "LABEL io.hyperi.contract.image-ref=\"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
2114        ));
2115        // The existing io.hyperi.profile label is unaffected.
2116        assert!(dockerfile.contains("LABEL io.hyperi.profile=\"production\""));
2117    }
2118
2119    #[test]
2120    fn chart_yaml_omits_identity_block_when_none() {
2121        let dir = tempfile::tempdir().unwrap();
2122        generate_chart(&test_contract(), dir.path(), None).unwrap();
2123        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
2124        assert!(!chart.contains("io.hyperi.contract"));
2125    }
2126
2127    #[test]
2128    fn chart_yaml_emits_three_identity_annotations_when_some() {
2129        let id = test_identity();
2130        let dir = tempfile::tempdir().unwrap();
2131        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
2132        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
2133        // Top-level annotations block present.
2134        assert!(chart.contains("\nannotations:\n"));
2135        assert!(chart.contains("io.hyperi.contract.version: \"v1\""));
2136        assert!(chart.contains(
2137            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
2138        ));
2139        assert!(
2140            chart.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
2141        );
2142    }
2143
2144    #[test]
2145    fn argocd_application_omits_identity_block_when_none() {
2146        let argo = ArgocdConfig::default();
2147        let yaml = generate_argocd_application(&test_contract(), &argo, None);
2148        assert!(!yaml.contains("io.hyperi.contract"));
2149        // sync-wave is unaffected.
2150        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
2151    }
2152
2153    #[test]
2154    fn argocd_application_emits_three_identity_annotations_when_some() {
2155        let id = test_identity();
2156        let argo = ArgocdConfig::default();
2157        let yaml = generate_argocd_application(&test_contract(), &argo, Some(&id));
2158        // Both the existing sync-wave AND the three identity keys must appear
2159        // under the same metadata.annotations block.
2160        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
2161        assert!(yaml.contains("io.hyperi.contract.version: \"v1\""));
2162        assert!(yaml.contains(
2163            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
2164        ));
2165        assert!(
2166            yaml.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
2167        );
2168    }
2169
2170    #[test]
2171    fn all_three_surfaces_share_the_same_key_prefix() {
2172        let id = test_identity();
2173        let argo = ArgocdConfig::default();
2174        let dir = tempfile::tempdir().unwrap();
2175
2176        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
2177        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
2178        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
2179        let app = generate_argocd_application(&test_contract(), &argo, Some(&id));
2180
2181        // The documented grep payoff: every surface mentions the prefix
2182        // exactly three times (once per key).
2183        assert_eq!(dockerfile.matches("io.hyperi.contract").count(), 3);
2184        assert_eq!(chart.matches("io.hyperi.contract").count(), 3);
2185        assert_eq!(app.matches("io.hyperi.contract").count(), 3);
2186    }
2187}