Skip to main content

hyperi_rustlib/deployment/generate/
helm.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/generate/helm.rs
3// Purpose:   Helm chart generation (Chart.yaml, values, templates)
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9#![allow(clippy::format_push_string)]
10
11use std::path::Path;
12
13use crate::deployment::contract::DeploymentContract;
14use crate::deployment::error::DeploymentError;
15
16use super::common::{safe_template_lookup, to_camel_suffix, write_file};
17
18// ============================================================================
19// Helm chart
20// ============================================================================
21
22/// Generate a complete Helm chart directory from the deployment contract.
23///
24/// Writes `Chart.yaml`, `values.yaml`, and all template files to `output_dir`.
25///
26/// `identity`, when provided, stamps the three `io.hyperi.contract.*`
27/// annotations into `Chart.yaml`'s top-level `annotations:` block per the
28/// Contract Identity Annotation Scheme v1. Phase 1 rollout: optional;
29/// callers SHOULD pass `Some(&identity)`.
30///
31/// # Errors
32///
33/// Returns `DeploymentError` if files or directories cannot be created.
34pub fn generate_chart(
35    contract: &DeploymentContract,
36    output_dir: impl AsRef<Path>,
37    identity: Option<&crate::deployment::ContractIdentity>,
38) -> Result<(), DeploymentError> {
39    let dir = output_dir.as_ref();
40    let templates_dir = dir.join("templates");
41
42    // Create directories
43    std::fs::create_dir_all(&templates_dir).map_err(|e| DeploymentError::CreateDir {
44        path: templates_dir.display().to_string(),
45        source: e,
46    })?;
47
48    // Write all chart files
49    write_file(dir.join("Chart.yaml"), &gen_chart_yaml(contract, identity))?;
50    write_file(dir.join("values.yaml"), &gen_values_yaml(contract))?;
51    write_file(
52        templates_dir.join("_helpers.tpl"),
53        &gen_helpers_tpl(contract),
54    )?;
55    write_file(
56        templates_dir.join("deployment.yaml"),
57        &gen_deployment_yaml(contract),
58    )?;
59    write_file(
60        templates_dir.join("service.yaml"),
61        &gen_service_yaml(contract),
62    )?;
63    write_file(
64        templates_dir.join("serviceaccount.yaml"),
65        &gen_serviceaccount_yaml(contract),
66    )?;
67    write_file(
68        templates_dir.join("configmap.yaml"),
69        &gen_configmap_yaml(contract),
70    )?;
71    write_file(
72        templates_dir.join("secret.yaml"),
73        &gen_secret_yaml(contract),
74    )?;
75    write_file(templates_dir.join("hpa.yaml"), &gen_hpa_yaml(contract))?;
76
77    if contract.keda.is_some() {
78        write_file(
79            templates_dir.join("keda-scaledobject.yaml"),
80            &gen_keda_scaledobject_yaml(contract),
81        )?;
82        write_file(
83            templates_dir.join("keda-triggerauth.yaml"),
84            &gen_keda_triggerauth_yaml(contract),
85        )?;
86    }
87
88    write_file(templates_dir.join("NOTES.txt"), &gen_notes_txt(contract))?;
89
90    Ok(())
91}
92
93// ============================================================================
94// Chart file generators
95// ============================================================================
96
97fn gen_chart_yaml(
98    c: &DeploymentContract,
99    identity: Option<&crate::deployment::ContractIdentity>,
100) -> String {
101    // Contract Identity Annotation Scheme v1 -- top-level annotations block.
102    let identity_block = identity
103        .map(|id| format!("\nannotations:\n{ann}\n", ann = id.as_yaml_annotations(2)))
104        .unwrap_or_default();
105
106    format!(
107        "apiVersion: v2\n\
108         name: {name}\n\
109         description: {desc}\n\
110         type: application\n\
111         version: 0.1.0\n\
112         appVersion: \"1.0.0\"\n\
113         {identity_block}\n\
114         keywords:\n\
115         \x20 - hyperi\n\
116         \x20 - dfe\n\
117         \n\
118         maintainers:\n\
119         \x20 - name: HyperI\n\
120         \x20   url: https://github.com/hyperi-io\n",
121        name = c.app_name,
122        desc = if c.description.is_empty() {
123            &c.app_name
124        } else {
125            &c.description
126        },
127        identity_block = identity_block,
128    )
129}
130
131#[allow(clippy::too_many_lines)]
132fn gen_values_yaml(c: &DeploymentContract) -> String {
133    let mut out = String::with_capacity(2048);
134
135    // Header comment
136    out.push_str(&format!(
137        "# {app} Helm chart values\n\
138         #\n\
139         # Generated by hyperi-rustlib deployment module.\n\
140         # Contract points validated by cargo test.\n\
141         \n",
142        app = c.app_name,
143    ));
144
145    // Replicas, image, overrides
146    out.push_str(&format!(
147        "# -- Number of replicas (ignored when KEDA is enabled)\n\
148         replicaCount: 1\n\
149         \n\
150         image:\n\
151         \x20 repository: {registry}/{app}\n\
152         \x20 # -- Defaults to Chart appVersion\n\
153         \x20 tag: \"\"\n\
154         \x20 pullPolicy: IfNotPresent\n\
155         \n\
156         imagePullSecrets: []\n\
157         nameOverride: \"\"\n\
158         fullnameOverride: \"\"\n\
159         \n",
160        registry = c.image_registry,
161        app = c.app_name,
162    ));
163
164    // Service account
165    out.push_str(
166        "serviceAccount:\n\
167         \x20 create: true\n\
168         \x20 annotations: {}\n\
169         \x20 # -- If not set, name is generated from fullname\n\
170         \x20 name: \"\"\n\
171         \n",
172    );
173
174    // Pod annotations (Prometheus)
175    out.push_str(&format!(
176        "# -- Pod annotations (Prometheus scrape config included by default)\n\
177         podAnnotations:\n\
178         \x20 prometheus.io/scrape: \"true\"\n\
179         \x20 prometheus.io/port: \"{port}\"\n\
180         \x20 prometheus.io/path: \"{metrics_path}\"\n\
181         \n\
182         podLabels: {{}}\n\
183         \n",
184        port = c.metrics_port,
185        metrics_path = c.health.metrics_path,
186    ));
187
188    // Resources
189    out.push_str(
190        "resources:\n\
191         \x20 requests:\n\
192         \x20   cpu: 250m\n\
193         \x20   memory: 256Mi\n\
194         \x20 limits:\n\
195         \x20   cpu: \"2\"\n\
196         \x20   memory: 1Gi\n\
197         \n",
198    );
199
200    // Service
201    out.push_str(&format!(
202        "# -- Metrics and health endpoint service\n\
203         service:\n\
204         \x20 type: ClusterIP\n\
205         \x20 port: {port}\n\
206         \n",
207        port = c.metrics_port,
208    ));
209
210    // App config section
211    out.push_str(&format!(
212        "# -- Application configuration (mounted as {})\n",
213        c.config_mount_path
214    ));
215    if let Some(ref config) = c.default_config {
216        out.push_str("config:\n");
217        // Serialise the config value as YAML and indent by 2
218        if let Ok(yaml) = serde_yaml_ng::to_string(config) {
219            for line in yaml.lines() {
220                if line == "---" {
221                    continue;
222                }
223                out.push_str(&format!("  {line}\n"));
224            }
225        }
226    } else {
227        out.push_str("config: {}\n");
228    }
229    out.push('\n');
230
231    // Secret sections
232    for group in &c.secrets {
233        out.push_str(&format!(
234            "# -- {} credentials\n\
235             {}:\n\
236             \x20 existingSecret: \"\"\n\
237             \x20 secretKeys:\n",
238            group.group_name, group.group_name,
239        ));
240        for env in &group.env_vars {
241            out.push_str(&format!("    {}: {}\n", env.key_name, env.secret_key));
242        }
243        for env in &group.env_vars {
244            out.push_str(&format!("  {}: \"\"\n", env.key_name));
245        }
246        out.push('\n');
247    }
248
249    // KEDA section. Always emit a `keda:` block in values.yaml even when
250    // the contract has no KEDA config -- templates reference
251    // `.Values.keda.enabled` unconditionally, so the key must exist or
252    // `helm lint` panics with "nil pointer evaluating interface
253    // {}.enabled". When the contract opts out, the block is just
254    // `enabled: false`.
255    if let Some(ref keda) = c.keda {
256        out.push_str(&format!(
257            "# -- KEDA autoscaling (requires KEDA operator installed)\n\
258             keda:\n\
259             \x20 enabled: true\n\
260             \x20 minReplicaCount: {min}\n\
261             \x20 maxReplicaCount: {max}\n\
262             \x20 pollingInterval: {poll}\n\
263             \x20 cooldownPeriod: {cool}\n\
264             \x20 kafka:\n\
265             \x20   # -- Scale when consumer group lag exceeds this per partition\n\
266             \x20   lagThreshold: \"{lag}\"\n\
267             \x20   # -- Wake from zero replicas when lag exceeds this\n\
268             \x20   activationLagThreshold: \"{activation}\"\n\
269             \x20   # -- Override topic (default: first topic from config)\n\
270             \x20   topic: \"\"\n\
271             \x20   # -- Override consumer group (default: from config)\n\
272             \x20   consumerGroup: \"\"\n\
273             \x20 cpu:\n\
274             \x20   enabled: {cpu_enabled}\n\
275             \x20   # -- CPU utilisation percentage threshold\n\
276             \x20   threshold: \"{cpu_threshold}\"\n\
277             \x20 scalingPressure:\n\
278             \x20   # -- Prometheus trigger on the correlated-composite\n\
279             \x20   # {metric_prefix}_scaling_pressure gauge (rustlib ScalingEngine).\n\
280             \x20   # Opt-in: set serverAddress to your Prometheus before enabling.\n\
281             \x20   enabled: {sp_enabled}\n\
282             \x20   # -- Prometheus endpoint KEDA queries (cluster-specific)\n\
283             \x20   serverAddress: \"\"\n\
284             \x20   # -- PromQL returning ONE scalar. The composite is a capped\n\
285             \x20   # per-pod 0-100 score, so avg across pods + metricType Value\n\
286             \x20   # (proportional). Never sum() a ratio. See docs/deployment/KEDA.md.\n\
287             \x20   query: \"avg({metric_prefix}_scaling_pressure)\"\n\
288             \x20   # -- Per-pod scaling_pressure target (gauge is 0-100)\n\
289             \x20   threshold: \"{sp_threshold}\"\n\
290             \n",
291            min = keda.min_replicas,
292            max = keda.max_replicas,
293            poll = keda.polling_interval,
294            cool = keda.cooldown_period,
295            lag = keda.kafka_lag_threshold,
296            activation = keda.activation_lag_threshold,
297            cpu_enabled = keda.cpu_enabled,
298            cpu_threshold = keda.cpu_threshold,
299            metric_prefix = c.metric_prefix,
300            sp_enabled = keda.scaling_pressure_enabled,
301            sp_threshold = keda.scaling_pressure_threshold,
302        ));
303    } else {
304        out.push_str(
305            "# -- KEDA autoscaling disabled by contract; HPA fallback below.\n\
306             keda:\n\
307             \x20 enabled: false\n\
308             \n",
309        );
310    }
311
312    // HPA fallback
313    out.push_str(
314        "# -- Standard HPA fallback (when KEDA is not installed)\n\
315         # Mutually exclusive with keda.enabled\n\
316         autoscaling:\n\
317         \x20 enabled: false\n\
318         \x20 minReplicas: 1\n\
319         \x20 maxReplicas: 10\n\
320         \x20 targetCPUUtilizationPercentage: 80\n\
321         \n\
322         nodeSelector: {}\n\
323         tolerations: []\n\
324         affinity: {}\n",
325    );
326
327    out
328}
329
330fn gen_helpers_tpl(c: &DeploymentContract) -> String {
331    let app = &c.app_name;
332    let mut out = String::with_capacity(2048);
333
334    // Standard helpers
335    out.push_str(&format!(
336        r#"{{{{/*
337Expand the name of the chart.
338*/}}}}
339{{{{- define "{app}.name" -}}}}
340{{{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}}}
341{{{{- end }}}}
342
343{{{{/*
344Create a default fully qualified app name.
345Truncated at 63 chars because some K8s name fields are limited.
346*/}}}}
347{{{{- define "{app}.fullname" -}}}}
348{{{{- if .Values.fullnameOverride }}}}
349{{{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}}}
350{{{{- else }}}}
351{{{{- $name := default .Chart.Name .Values.nameOverride }}}}
352{{{{- if contains $name .Release.Name }}}}
353{{{{- .Release.Name | trunc 63 | trimSuffix "-" }}}}
354{{{{- else }}}}
355{{{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}}}
356{{{{- end }}}}
357{{{{- end }}}}
358{{{{- end }}}}
359
360{{{{/*
361Create chart name and version as used by the chart label.
362*/}}}}
363{{{{- define "{app}.chart" -}}}}
364{{{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}}}
365{{{{- end }}}}
366
367{{{{/*
368Common labels.
369*/}}}}
370{{{{- define "{app}.labels" -}}}}
371helm.sh/chart: {{{{ include "{app}.chart" . }}}}
372{{{{ include "{app}.selectorLabels" . }}}}
373{{{{- if .Chart.AppVersion }}}}
374app.kubernetes.io/version: {{{{ .Chart.AppVersion | quote }}}}
375{{{{- end }}}}
376app.kubernetes.io/managed-by: {{{{ .Release.Service }}}}
377{{{{- end }}}}
378
379{{{{/*
380Selector labels.
381*/}}}}
382{{{{- define "{app}.selectorLabels" -}}}}
383app.kubernetes.io/name: {{{{ include "{app}.name" . }}}}
384app.kubernetes.io/instance: {{{{ .Release.Name }}}}
385{{{{- end }}}}
386
387{{{{/*
388Service account name.
389*/}}}}
390{{{{- define "{app}.serviceAccountName" -}}}}
391{{{{- if .Values.serviceAccount.create }}}}
392{{{{- default (include "{app}.fullname" .) .Values.serviceAccount.name }}}}
393{{{{- else }}}}
394{{{{- default "default" .Values.serviceAccount.name }}}}
395{{{{- end }}}}
396{{{{- end }}}}
397"#,
398    ));
399
400    // Secret name helpers -- one per secret group
401    for group in &c.secrets {
402        let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
403        out.push_str(&format!(
404            r#"
405{{{{/*
406{group} secret name -- use existing or generate from fullname.
407*/}}}}
408{{{{- define "{app}.{helper}" -}}}}
409{{{{- if .Values.{group}.existingSecret }}}}
410{{{{- .Values.{group}.existingSecret }}}}
411{{{{- else }}}}
412{{{{- printf "%s-{group}" (include "{app}.fullname" .) }}}}
413{{{{- end }}}}
414{{{{- end }}}}
415"#,
416            app = app,
417            group = group.group_name,
418            helper = helper_name,
419        ));
420    }
421
422    out
423}
424
425fn gen_deployment_yaml(c: &DeploymentContract) -> String {
426    let app = &c.app_name;
427    let mut out = String::with_capacity(4096);
428
429    // Header
430    out.push_str(&format!(
431        r#"apiVersion: apps/v1
432kind: Deployment
433metadata:
434  name: {{{{ include "{app}.fullname" . }}}}
435  labels:
436    {{{{- include "{app}.labels" . | nindent 4 }}}}
437spec:
438  {{{{- if not (or .Values.keda.enabled .Values.autoscaling.enabled) }}}}
439  replicas: {{{{ .Values.replicaCount }}}}
440  {{{{- end }}}}
441  selector:
442    matchLabels:
443      {{{{- include "{app}.selectorLabels" . | nindent 6 }}}}
444  template:
445    metadata:
446      annotations:
447        checksum/config: {{{{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}}}
448        {{{{- with .Values.podAnnotations }}}}
449        {{{{- toYaml . | nindent 8 }}}}
450        {{{{- end }}}}
451      labels:
452        {{{{- include "{app}.labels" . | nindent 8 }}}}
453        {{{{- with .Values.podLabels }}}}
454        {{{{- toYaml . | nindent 8 }}}}
455        {{{{- end }}}}
456    spec:
457      {{{{- with .Values.imagePullSecrets }}}}
458      imagePullSecrets:
459        {{{{- toYaml . | nindent 8 }}}}
460      {{{{- end }}}}
461      serviceAccountName: {{{{ include "{app}.serviceAccountName" . }}}}
462      containers:
463        - name: {{{{ .Chart.Name }}}}
464          image: "{{{{ .Values.image.repository }}}}:{{{{ .Values.image.tag | default .Chart.AppVersion }}}}"
465          imagePullPolicy: {{{{ .Values.image.pullPolicy }}}}
466"#,
467    ));
468
469    // Args
470    if !c.entrypoint_args.is_empty() {
471        out.push_str("          args:\n");
472        for arg in &c.entrypoint_args {
473            out.push_str(&format!("            - \"{arg}\"\n"));
474        }
475    }
476
477    // Ports
478    out.push_str(
479        "          ports:\n\
480         \x20           - name: metrics\n\
481         \x20             containerPort: {{ .Values.service.port }}\n\
482         \x20             protocol: TCP\n",
483    );
484    for port in &c.extra_ports {
485        out.push_str(&format!(
486            "            - name: {name}\n\
487             \x20             containerPort: {port}\n\
488             \x20             protocol: {proto}\n",
489            name = port.name,
490            port = port.port,
491            proto = port.protocol,
492        ));
493    }
494
495    // Env vars from secrets
496    if !c.secrets.is_empty() {
497        out.push_str("          env:\n");
498        for group in &c.secrets {
499            let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
500            out.push_str(&format!(
501                "            # {} credentials via Secret (figment env cascade overrides file config)\n",
502                group.group_name
503            ));
504            for env in &group.env_vars {
505                // See gen_secret_yaml -- hyphenated keys must use index form.
506                let key_lookup = safe_template_lookup(
507                    &format!(".Values.{}.secretKeys", group.group_name),
508                    &env.key_name,
509                );
510                out.push_str(&format!(
511                    "            - name: {env_var}\n\
512                     \x20             valueFrom:\n\
513                     \x20               secretKeyRef:\n\
514                     \x20                 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
515                     \x20                 key: {{{{ {key_lookup} }}}}\n",
516                    env_var = env.env_var,
517                    app = app,
518                    helper = helper_name,
519                ));
520            }
521        }
522    }
523
524    // Probes
525    out.push_str(&format!(
526        "          livenessProbe:\n\
527         \x20           httpGet:\n\
528         \x20             path: {liveness}\n\
529         \x20             port: metrics\n\
530         \x20           initialDelaySeconds: 10\n\
531         \x20           periodSeconds: 10\n\
532         \x20           failureThreshold: 3\n\
533         \x20         readinessProbe:\n\
534         \x20           httpGet:\n\
535         \x20             path: {readiness}\n\
536         \x20             port: metrics\n\
537         \x20           initialDelaySeconds: 5\n\
538         \x20           periodSeconds: 5\n\
539         \x20           failureThreshold: 2\n\
540         \x20         startupProbe:\n\
541         \x20           httpGet:\n\
542         \x20             path: {liveness}\n\
543         \x20             port: metrics\n\
544         \x20           failureThreshold: 30\n\
545         \x20           periodSeconds: 5\n",
546        liveness = c.health.liveness_path,
547        readiness = c.health.readiness_path,
548    ));
549
550    // Volume mounts
551    out.push_str(&format!(
552        "          volumeMounts:\n\
553         \x20           - name: config\n\
554         \x20             mountPath: {config_dir}\n\
555         \x20             readOnly: true\n",
556        config_dir = c.config_dir(),
557    ));
558
559    // Resources
560    out.push_str(
561        "          {{- with .Values.resources }}\n\
562         \x20         resources:\n\
563         \x20           {{- toYaml . | nindent 12 }}\n\
564         \x20         {{- end }}\n",
565    );
566
567    // Volumes
568    out.push_str(&format!(
569        "      volumes:\n\
570         \x20       - name: config\n\
571         \x20         configMap:\n\
572         \x20           name: {{{{ include \"{app}.fullname\" . }}}}-config\n",
573    ));
574
575    // Node selector, affinity, tolerations
576    out.push_str(
577        "      {{- with .Values.nodeSelector }}\n\
578         \x20     nodeSelector:\n\
579         \x20       {{- toYaml . | nindent 8 }}\n\
580         \x20     {{- end }}\n\
581         \x20     {{- with .Values.affinity }}\n\
582         \x20     affinity:\n\
583         \x20       {{- toYaml . | nindent 8 }}\n\
584         \x20     {{- end }}\n\
585         \x20     {{- with .Values.tolerations }}\n\
586         \x20     tolerations:\n\
587         \x20       {{- toYaml . | nindent 8 }}\n\
588         \x20     {{- end }}\n",
589    );
590
591    out
592}
593
594fn gen_service_yaml(c: &DeploymentContract) -> String {
595    let app = &c.app_name;
596    let mut out = format!(
597        r#"apiVersion: v1
598kind: Service
599metadata:
600  name: {{{{ include "{app}.fullname" . }}}}
601  labels:
602    {{{{- include "{app}.labels" . | nindent 4 }}}}
603spec:
604  type: {{{{ .Values.service.type }}}}
605  ports:
606    - port: {{{{ .Values.service.port }}}}
607      targetPort: metrics
608      protocol: TCP
609      name: metrics
610"#,
611    );
612
613    // Extra ports
614    for port in &c.extra_ports {
615        out.push_str(&format!(
616            "    - port: {port}\n\
617             \x20     targetPort: {port}\n\
618             \x20     protocol: {proto}\n\
619             \x20     name: {name}\n",
620            port = port.port,
621            proto = port.protocol,
622            name = port.name,
623        ));
624    }
625
626    out.push_str(&format!(
627        "  selector:\n\
628         \x20   {{{{- include \"{app}.selectorLabels\" . | nindent 4 }}}}\n",
629    ));
630
631    out
632}
633
634fn gen_serviceaccount_yaml(c: &DeploymentContract) -> String {
635    let app = &c.app_name;
636    format!(
637        r#"{{{{- if .Values.serviceAccount.create -}}}}
638apiVersion: v1
639kind: ServiceAccount
640metadata:
641  name: {{{{ include "{app}.serviceAccountName" . }}}}
642  labels:
643    {{{{- include "{app}.labels" . | nindent 4 }}}}
644  {{{{- with .Values.serviceAccount.annotations }}}}
645  annotations:
646    {{{{- toYaml . | nindent 4 }}}}
647  {{{{- end }}}}
648automountServiceAccountToken: false
649{{{{- end }}}}
650"#,
651    )
652}
653
654fn gen_configmap_yaml(c: &DeploymentContract) -> String {
655    let app = &c.app_name;
656
657    let mut out = format!(
658        r#"apiVersion: v1
659kind: ConfigMap
660metadata:
661  name: {{{{ include "{app}.fullname" . }}}}-config
662  labels:
663    {{{{- include "{app}.labels" . | nindent 4 }}}}
664data:
665  {filename}: |
666    {{{{- toYaml .Values.config | nindent 4 }}}}
667"#,
668        app = app,
669        filename = c.config_filename(),
670    );
671
672    let _ = &mut out; // keep borrow checker happy
673    out
674}
675
676fn gen_secret_yaml(c: &DeploymentContract) -> String {
677    let app = &c.app_name;
678    let mut out = String::new();
679    let mut first = true;
680
681    for group in &c.secrets {
682        if !first {
683            out.push_str("---\n");
684        }
685        first = false;
686
687        let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
688
689        out.push_str(&format!(
690            "{{{{- if not .Values.{group}.existingSecret }}}}\n\
691             apiVersion: v1\n\
692             kind: Secret\n\
693             metadata:\n\
694             \x20 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
695             \x20 labels:\n\
696             \x20   {{{{- include \"{app}.labels\" . | nindent 4 }}}}\n\
697             type: Opaque\n\
698             data:\n",
699            group = group.group_name,
700            app = app,
701            helper = helper_name,
702        ));
703
704        for env in &group.env_vars {
705            // Hyphenated or otherwise non-Go-identifier-safe key names
706            // require `(index .Values.x "key")` form instead of
707            // `.Values.x.key` -- Go-template parser rejects hyphens etc.
708            let key_lookup = safe_template_lookup(
709                &format!(".Values.{}.secretKeys", group.group_name),
710                &env.key_name,
711            );
712            let val_lookup =
713                safe_template_lookup(&format!(".Values.{}", group.group_name), &env.key_name);
714            out.push_str(&format!(
715                "  {{{{ {key_lookup} }}}}: {{{{ {val_lookup} | b64enc | quote }}}}\n"
716            ));
717        }
718
719        out.push_str("{{- end }}\n");
720    }
721
722    if c.secrets.is_empty() {
723        out.push_str("# No secrets defined in deployment contract\n");
724    }
725
726    out
727}
728
729fn gen_hpa_yaml(c: &DeploymentContract) -> String {
730    let app = &c.app_name;
731    format!(
732        r#"{{{{- if and .Values.autoscaling.enabled (not .Values.keda.enabled) }}}}
733# Standard HPA fallback -- use when KEDA operator is not installed.
734# Mutually exclusive with keda.enabled (KEDA creates its own HPA).
735apiVersion: autoscaling/v2
736kind: HorizontalPodAutoscaler
737metadata:
738  name: {{{{ include "{app}.fullname" . }}}}
739  labels:
740    {{{{- include "{app}.labels" . | nindent 4 }}}}
741spec:
742  scaleTargetRef:
743    apiVersion: apps/v1
744    kind: Deployment
745    name: {{{{ include "{app}.fullname" . }}}}
746  minReplicas: {{{{ .Values.autoscaling.minReplicas }}}}
747  maxReplicas: {{{{ .Values.autoscaling.maxReplicas }}}}
748  metrics:
749    - type: Resource
750      resource:
751        name: cpu
752        target:
753          type: Utilization
754          averageUtilization: {{{{ .Values.autoscaling.targetCPUUtilizationPercentage }}}}
755{{{{- end }}}}
756"#,
757    )
758}
759
760fn gen_keda_scaledobject_yaml(c: &DeploymentContract) -> String {
761    let app = &c.app_name;
762
763    // Find kafka secret group for trigger auth reference
764    let has_kafka_secret = c.secrets.iter().any(|g| g.group_name == "kafka");
765
766    let auth_ref = if has_kafka_secret {
767        format!(
768            "      authenticationRef:\n\
769             \x20       name: {{{{ include \"{app}.fullname\" . }}}}-kafka-auth\n"
770        )
771    } else {
772        String::new()
773    };
774
775    format!(
776        r#"{{{{- if .Values.keda.enabled }}}}
777apiVersion: keda.sh/v1alpha1
778kind: ScaledObject
779metadata:
780  name: {{{{ include "{app}.fullname" . }}}}
781  labels:
782    {{{{- include "{app}.labels" . | nindent 4 }}}}
783spec:
784  scaleTargetRef:
785    name: {{{{ include "{app}.fullname" . }}}}
786  minReplicaCount: {{{{ .Values.keda.minReplicaCount }}}}
787  maxReplicaCount: {{{{ .Values.keda.maxReplicaCount }}}}
788  pollingInterval: {{{{ .Values.keda.pollingInterval }}}}
789  cooldownPeriod: {{{{ .Values.keda.cooldownPeriod }}}}
790  triggers:
791    # Kafka consumer group lag (primary scaler)
792    - type: kafka
793{auth_ref}      metadata:
794        bootstrapServers: {{{{ .Values.config.kafka.brokers | quote }}}}
795        consumerGroup: {{{{ .Values.keda.kafka.consumerGroup | default .Values.config.kafka.group_id | quote }}}}
796        {{{{- /* `default (index X 0)` would eagerly evaluate `index nil 0` and fail
797            lint when no topics are set. Use explicit conditional instead. */}}}}
798        {{{{- if .Values.keda.kafka.topic }}}}
799        topic: {{{{ .Values.keda.kafka.topic | quote }}}}
800        {{{{- else if .Values.config.kafka.topics }}}}
801        topic: {{{{ (index .Values.config.kafka.topics 0) | quote }}}}
802        {{{{- else }}}}
803        topic: ""
804        {{{{- end }}}}
805        lagThreshold: {{{{ .Values.keda.kafka.lagThreshold | quote }}}}
806        activationLagThreshold: {{{{ .Values.keda.kafka.activationLagThreshold | quote }}}}
807        saslType: scram_sha512
808        tls: disable
809    {{{{- if .Values.keda.cpu.enabled }}}}
810    # CPU utilisation (secondary scaler)
811    - type: cpu
812      metricType: Utilization
813      metadata:
814        value: {{{{ .Values.keda.cpu.threshold | quote }}}}
815    {{{{- end }}}}
816    {{{{- if .Values.keda.scalingPressure.enabled }}}}
817    # Correlated-composite scaling pressure (rustlib ScalingEngine).
818    # The composite is a capped per-pod 0-100 score: query avg()s it across
819    # pods and metricType Value scales proportionally to hold avg <= threshold.
820    - type: prometheus
821      metricType: Value
822      metadata:
823        serverAddress: {{{{ .Values.keda.scalingPressure.serverAddress | quote }}}}
824        query: {{{{ .Values.keda.scalingPressure.query | quote }}}}
825        threshold: {{{{ .Values.keda.scalingPressure.threshold | quote }}}}
826    {{{{- end }}}}
827{{{{- end }}}}
828"#,
829    )
830}
831
832fn gen_keda_triggerauth_yaml(c: &DeploymentContract) -> String {
833    let app = &c.app_name;
834
835    // Find the kafka secret group
836    let kafka_group = c.secrets.iter().find(|g| g.group_name == "kafka");
837
838    if kafka_group.is_none() {
839        return "# No kafka secret group -- KEDA TriggerAuthentication not generated\n".to_string();
840    }
841
842    let helper_name = format!("{}SecretName", to_camel_suffix("kafka"));
843
844    format!(
845        r#"{{{{- if .Values.keda.enabled }}}}
846apiVersion: keda.sh/v1alpha1
847kind: TriggerAuthentication
848metadata:
849  name: {{{{ include "{app}.fullname" . }}}}-kafka-auth
850  labels:
851    {{{{- include "{app}.labels" . | nindent 4 }}}}
852spec:
853  secretTargetRef:
854    - parameter: sasl
855      name: {{{{ include "{app}.{helper_name}" . }}}}
856      key: {{{{ .Values.kafka.secretKeys.username }}}}
857    - parameter: password
858      name: {{{{ include "{app}.{helper_name}" . }}}}
859      key: {{{{ .Values.kafka.secretKeys.password }}}}
860{{{{- end }}}}
861"#,
862    )
863}
864
865fn gen_notes_txt(c: &DeploymentContract) -> String {
866    let app = &c.app_name;
867
868    format!(
869        r#"{app} has been deployed.
870
8711. Get the metrics/health endpoint:
872   kubectl port-forward svc/{{{{ include "{app}.fullname" . }}}} {{{{ .Values.service.port }}}}:{{{{ .Values.service.port }}}}
873   curl http://localhost:{{{{ .Values.service.port }}}}{liveness}
874   curl http://localhost:{{{{ .Values.service.port }}}}{metrics}
875
876{{{{- if .Values.keda.enabled }}}}
877
8782. Check KEDA autoscaling status:
879   kubectl get scaledobject {{{{ include "{app}.fullname" . }}}}
880   kubectl get hpa
881{{{{- end }}}}
882
8833. View logs:
884   kubectl logs -l app.kubernetes.io/name={{{{ include "{app}.name" . }}}} -f
885"#,
886        app = app,
887        liveness = c.health.liveness_path,
888        metrics = c.health.metrics_path,
889    )
890}