hyperi_rustlib/deployment/generate/
helm.rs1#![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
18pub 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 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_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
93fn gen_chart_yaml(
98 c: &DeploymentContract,
99 identity: Option<&crate::deployment::ContractIdentity>,
100) -> String {
101 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; 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 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 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 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}