1#![allow(clippy::format_push_string)] use std::path::Path;
18
19use super::contract::{DeploymentContract, ImageProfile};
20use super::error::DeploymentError;
21
22#[must_use]
39pub fn generate_dockerfile(
40 contract: &DeploymentContract,
41 identity: Option<&crate::deployment::ContractIdentity>,
42) -> String {
43 let binary = contract.binary();
44
45 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 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 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 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: FSL-1.1-ALv2
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
127pub 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#[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
305const DEV_TOOLS: &[&str] = &[
307 "bash",
308 "strace",
309 "tcpdump",
310 "procps",
311 "dnsutils",
312 "net-tools",
313 "less",
314 "jq",
315];
316
317fn 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 let mut base_pkgs = vec!["ca-certificates", "curl", "netcat-openbsd", "iputils-ping"];
328
329 if !deps.apt_repos.is_empty() {
331 base_pkgs.push("gnupg");
332 }
333
334 if is_dev {
336 base_pkgs.extend_from_slice(DEV_TOOLS);
337 }
338
339 if deps.is_empty() {
340 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 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 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 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 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 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#[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 out.push_str(&format!(
405 "# Generated by hyperi-rustlib deployment module\nservices:\n {}:\n",
406 contract.app_name
407 ));
408
409 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 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 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 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 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 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
467pub 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 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_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
542fn gen_chart_yaml(
547 c: &DeploymentContract,
548 identity: Option<&crate::deployment::ContractIdentity>,
549) -> String {
550 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; 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 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 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 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#[derive(Debug, Clone)]
1323pub struct ArgocdConfig {
1324 pub argocd_namespace: String,
1326 pub dest_namespace: String,
1328 pub dest_server: String,
1330 pub repo_url: String,
1332 pub target_revision: String,
1334 pub chart_path: String,
1336 pub project: String,
1338 pub sync_wave: i32,
1340 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#[must_use]
1395pub fn generate_argocd_application(
1396 contract: &DeploymentContract,
1397 argo: &ArgocdConfig,
1398 identity: Option<&crate::deployment::ContractIdentity>,
1399) -> String {
1400 let identity_block = identity
1404 .map(|id| format!("\n{ann}", ann = id.as_yaml_annotations(4)))
1405 .unwrap_or_default();
1406
1407 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
1501fn 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
1524fn 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
1539fn 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#[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 assert!(dockerfile.contains("packages.confluent.io"));
1654 assert!(dockerfile.contains("confluent-clients.gpg"));
1655 assert!(dockerfile.contains("librdkafka1"));
1657 assert!(dockerfile.contains("libssl3"));
1658 assert!(dockerfile.contains("libzstd1"));
1659 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 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 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 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 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 assert!(!is_go_identifier("bearer-tokens")); assert!(!is_go_identifier("foo.bar")); assert!(!is_go_identifier("123foo")); assert!(!is_go_identifier("")); assert!(!is_go_identifier("foo bar")); assert!(!is_go_identifier("foo:bar")); }
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 #[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 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 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 #[test]
1918 fn test_secret_yaml_handles_hyphenated_key_names() {
1919 let mut contract = test_contract();
1920 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 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 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 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 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 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 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 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 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 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}