#![allow(clippy::format_push_string)]
use std::path::Path;
use crate::deployment::contract::DeploymentContract;
use crate::deployment::error::DeploymentError;
use super::common::{safe_template_lookup, to_camel_suffix, write_file};
pub fn generate_chart(
contract: &DeploymentContract,
output_dir: impl AsRef<Path>,
identity: Option<&crate::deployment::ContractIdentity>,
) -> Result<(), DeploymentError> {
let dir = output_dir.as_ref();
let templates_dir = dir.join("templates");
std::fs::create_dir_all(&templates_dir).map_err(|e| DeploymentError::CreateDir {
path: templates_dir.display().to_string(),
source: e,
})?;
write_file(dir.join("Chart.yaml"), &gen_chart_yaml(contract, identity))?;
write_file(dir.join("values.yaml"), &gen_values_yaml(contract))?;
write_file(
templates_dir.join("_helpers.tpl"),
&gen_helpers_tpl(contract),
)?;
write_file(
templates_dir.join("deployment.yaml"),
&gen_deployment_yaml(contract),
)?;
write_file(
templates_dir.join("service.yaml"),
&gen_service_yaml(contract),
)?;
write_file(
templates_dir.join("serviceaccount.yaml"),
&gen_serviceaccount_yaml(contract),
)?;
write_file(
templates_dir.join("configmap.yaml"),
&gen_configmap_yaml(contract),
)?;
write_file(
templates_dir.join("secret.yaml"),
&gen_secret_yaml(contract),
)?;
write_file(templates_dir.join("hpa.yaml"), &gen_hpa_yaml(contract))?;
if contract.keda.is_some() {
write_file(
templates_dir.join("keda-scaledobject.yaml"),
&gen_keda_scaledobject_yaml(contract),
)?;
write_file(
templates_dir.join("keda-triggerauth.yaml"),
&gen_keda_triggerauth_yaml(contract),
)?;
}
write_file(templates_dir.join("NOTES.txt"), &gen_notes_txt(contract))?;
Ok(())
}
fn gen_chart_yaml(
c: &DeploymentContract,
identity: Option<&crate::deployment::ContractIdentity>,
) -> String {
let identity_block = identity
.map(|id| format!("\nannotations:\n{ann}\n", ann = id.as_yaml_annotations(2)))
.unwrap_or_default();
format!(
"apiVersion: v2\n\
name: {name}\n\
description: {desc}\n\
type: application\n\
version: 0.1.0\n\
appVersion: \"1.0.0\"\n\
{identity_block}\n\
keywords:\n\
\x20 - hyperi\n\
\x20 - dfe\n\
\n\
maintainers:\n\
\x20 - name: HyperI\n\
\x20 url: https://github.com/hyperi-io\n",
name = c.app_name,
desc = if c.description.is_empty() {
&c.app_name
} else {
&c.description
},
identity_block = identity_block,
)
}
#[allow(clippy::too_many_lines)]
fn gen_values_yaml(c: &DeploymentContract) -> String {
let mut out = String::with_capacity(2048);
out.push_str(&format!(
"# {app} Helm chart values\n\
#\n\
# Generated by hyperi-rustlib deployment module.\n\
# Contract points validated by cargo test.\n\
\n",
app = c.app_name,
));
out.push_str(&format!(
"# -- Number of replicas (ignored when KEDA is enabled)\n\
replicaCount: 1\n\
\n\
image:\n\
\x20 repository: {registry}/{app}\n\
\x20 # -- Defaults to Chart appVersion\n\
\x20 tag: \"\"\n\
\x20 pullPolicy: IfNotPresent\n\
\n\
imagePullSecrets: []\n\
nameOverride: \"\"\n\
fullnameOverride: \"\"\n\
\n",
registry = c.image_registry,
app = c.app_name,
));
out.push_str(
"serviceAccount:\n\
\x20 create: true\n\
\x20 annotations: {}\n\
\x20 # -- If not set, name is generated from fullname\n\
\x20 name: \"\"\n\
\n",
);
out.push_str(&format!(
"# -- Pod annotations (Prometheus scrape config included by default)\n\
podAnnotations:\n\
\x20 prometheus.io/scrape: \"true\"\n\
\x20 prometheus.io/port: \"{port}\"\n\
\x20 prometheus.io/path: \"{metrics_path}\"\n\
\n\
podLabels: {{}}\n\
\n",
port = c.metrics_port,
metrics_path = c.health.metrics_path,
));
out.push_str(
"resources:\n\
\x20 requests:\n\
\x20 cpu: 250m\n\
\x20 memory: 256Mi\n\
\x20 limits:\n\
\x20 cpu: \"2\"\n\
\x20 memory: 1Gi\n\
\n",
);
out.push_str(&format!(
"# -- Metrics and health endpoint service\n\
service:\n\
\x20 type: ClusterIP\n\
\x20 port: {port}\n\
\n",
port = c.metrics_port,
));
out.push_str(&format!(
"# -- Application configuration (mounted as {})\n",
c.config_mount_path
));
if let Some(ref config) = c.default_config {
out.push_str("config:\n");
if let Ok(yaml) = serde_yaml_ng::to_string(config) {
for line in yaml.lines() {
if line == "---" {
continue;
}
out.push_str(&format!(" {line}\n"));
}
}
} else {
out.push_str("config: {}\n");
}
out.push('\n');
for group in &c.secrets {
out.push_str(&format!(
"# -- {} credentials\n\
{}:\n\
\x20 existingSecret: \"\"\n\
\x20 secretKeys:\n",
group.group_name, group.group_name,
));
for env in &group.env_vars {
out.push_str(&format!(" {}: {}\n", env.key_name, env.secret_key));
}
for env in &group.env_vars {
out.push_str(&format!(" {}: \"\"\n", env.key_name));
}
out.push('\n');
}
if let Some(ref keda) = c.keda {
out.push_str(&format!(
"# -- KEDA autoscaling (requires KEDA operator installed)\n\
keda:\n\
\x20 enabled: true\n\
\x20 minReplicaCount: {min}\n\
\x20 maxReplicaCount: {max}\n\
\x20 pollingInterval: {poll}\n\
\x20 cooldownPeriod: {cool}\n\
\x20 kafka:\n\
\x20 # -- Scale when consumer group lag exceeds this per partition\n\
\x20 lagThreshold: \"{lag}\"\n\
\x20 # -- Wake from zero replicas when lag exceeds this\n\
\x20 activationLagThreshold: \"{activation}\"\n\
\x20 # -- Override topic (default: first topic from config)\n\
\x20 topic: \"\"\n\
\x20 # -- Override consumer group (default: from config)\n\
\x20 consumerGroup: \"\"\n\
\x20 cpu:\n\
\x20 enabled: {cpu_enabled}\n\
\x20 # -- CPU utilisation percentage threshold\n\
\x20 threshold: \"{cpu_threshold}\"\n\
\n",
min = keda.min_replicas,
max = keda.max_replicas,
poll = keda.polling_interval,
cool = keda.cooldown_period,
lag = keda.kafka_lag_threshold,
activation = keda.activation_lag_threshold,
cpu_enabled = keda.cpu_enabled,
cpu_threshold = keda.cpu_threshold,
));
} else {
out.push_str(
"# -- KEDA autoscaling disabled by contract; HPA fallback below.\n\
keda:\n\
\x20 enabled: false\n\
\n",
);
}
out.push_str(
"# -- Standard HPA fallback (when KEDA is not installed)\n\
# Mutually exclusive with keda.enabled\n\
autoscaling:\n\
\x20 enabled: false\n\
\x20 minReplicas: 1\n\
\x20 maxReplicas: 10\n\
\x20 targetCPUUtilizationPercentage: 80\n\
\n\
nodeSelector: {}\n\
tolerations: []\n\
affinity: {}\n",
);
out
}
fn gen_helpers_tpl(c: &DeploymentContract) -> String {
let app = &c.app_name;
let mut out = String::with_capacity(2048);
out.push_str(&format!(
r#"{{{{/*
Expand the name of the chart.
*/}}}}
{{{{- define "{app}.name" -}}}}
{{{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}}}
{{{{- end }}}}
{{{{/*
Create a default fully qualified app name.
Truncated at 63 chars because some K8s name fields are limited.
*/}}}}
{{{{- define "{app}.fullname" -}}}}
{{{{- if .Values.fullnameOverride }}}}
{{{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}}}
{{{{- else }}}}
{{{{- $name := default .Chart.Name .Values.nameOverride }}}}
{{{{- if contains $name .Release.Name }}}}
{{{{- .Release.Name | trunc 63 | trimSuffix "-" }}}}
{{{{- else }}}}
{{{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}}}
{{{{- end }}}}
{{{{- end }}}}
{{{{- end }}}}
{{{{/*
Create chart name and version as used by the chart label.
*/}}}}
{{{{- define "{app}.chart" -}}}}
{{{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}}}
{{{{- end }}}}
{{{{/*
Common labels.
*/}}}}
{{{{- define "{app}.labels" -}}}}
helm.sh/chart: {{{{ include "{app}.chart" . }}}}
{{{{ include "{app}.selectorLabels" . }}}}
{{{{- if .Chart.AppVersion }}}}
app.kubernetes.io/version: {{{{ .Chart.AppVersion | quote }}}}
{{{{- end }}}}
app.kubernetes.io/managed-by: {{{{ .Release.Service }}}}
{{{{- end }}}}
{{{{/*
Selector labels.
*/}}}}
{{{{- define "{app}.selectorLabels" -}}}}
app.kubernetes.io/name: {{{{ include "{app}.name" . }}}}
app.kubernetes.io/instance: {{{{ .Release.Name }}}}
{{{{- end }}}}
{{{{/*
Service account name.
*/}}}}
{{{{- define "{app}.serviceAccountName" -}}}}
{{{{- if .Values.serviceAccount.create }}}}
{{{{- default (include "{app}.fullname" .) .Values.serviceAccount.name }}}}
{{{{- else }}}}
{{{{- default "default" .Values.serviceAccount.name }}}}
{{{{- end }}}}
{{{{- end }}}}
"#,
));
for group in &c.secrets {
let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
out.push_str(&format!(
r#"
{{{{/*
{group} secret name -- use existing or generate from fullname.
*/}}}}
{{{{- define "{app}.{helper}" -}}}}
{{{{- if .Values.{group}.existingSecret }}}}
{{{{- .Values.{group}.existingSecret }}}}
{{{{- else }}}}
{{{{- printf "%s-{group}" (include "{app}.fullname" .) }}}}
{{{{- end }}}}
{{{{- end }}}}
"#,
app = app,
group = group.group_name,
helper = helper_name,
));
}
out
}
fn gen_deployment_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
let mut out = String::with_capacity(4096);
out.push_str(&format!(
r#"apiVersion: apps/v1
kind: Deployment
metadata:
name: {{{{ include "{app}.fullname" . }}}}
labels:
{{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
{{{{- if not (or .Values.keda.enabled .Values.autoscaling.enabled) }}}}
replicas: {{{{ .Values.replicaCount }}}}
{{{{- end }}}}
selector:
matchLabels:
{{{{- include "{app}.selectorLabels" . | nindent 6 }}}}
template:
metadata:
annotations:
checksum/config: {{{{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}}}
{{{{- with .Values.podAnnotations }}}}
{{{{- toYaml . | nindent 8 }}}}
{{{{- end }}}}
labels:
{{{{- include "{app}.labels" . | nindent 8 }}}}
{{{{- with .Values.podLabels }}}}
{{{{- toYaml . | nindent 8 }}}}
{{{{- end }}}}
spec:
{{{{- with .Values.imagePullSecrets }}}}
imagePullSecrets:
{{{{- toYaml . | nindent 8 }}}}
{{{{- end }}}}
serviceAccountName: {{{{ include "{app}.serviceAccountName" . }}}}
containers:
- name: {{{{ .Chart.Name }}}}
image: "{{{{ .Values.image.repository }}}}:{{{{ .Values.image.tag | default .Chart.AppVersion }}}}"
imagePullPolicy: {{{{ .Values.image.pullPolicy }}}}
"#,
));
if !c.entrypoint_args.is_empty() {
out.push_str(" args:\n");
for arg in &c.entrypoint_args {
out.push_str(&format!(" - \"{arg}\"\n"));
}
}
out.push_str(
" ports:\n\
\x20 - name: metrics\n\
\x20 containerPort: {{ .Values.service.port }}\n\
\x20 protocol: TCP\n",
);
for port in &c.extra_ports {
out.push_str(&format!(
" - name: {name}\n\
\x20 containerPort: {port}\n\
\x20 protocol: {proto}\n",
name = port.name,
port = port.port,
proto = port.protocol,
));
}
if !c.secrets.is_empty() {
out.push_str(" env:\n");
for group in &c.secrets {
let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
out.push_str(&format!(
" # {} credentials via Secret (figment env cascade overrides file config)\n",
group.group_name
));
for env in &group.env_vars {
let key_lookup = safe_template_lookup(
&format!(".Values.{}.secretKeys", group.group_name),
&env.key_name,
);
out.push_str(&format!(
" - name: {env_var}\n\
\x20 valueFrom:\n\
\x20 secretKeyRef:\n\
\x20 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
\x20 key: {{{{ {key_lookup} }}}}\n",
env_var = env.env_var,
app = app,
helper = helper_name,
));
}
}
}
out.push_str(&format!(
" livenessProbe:\n\
\x20 httpGet:\n\
\x20 path: {liveness}\n\
\x20 port: metrics\n\
\x20 initialDelaySeconds: 10\n\
\x20 periodSeconds: 10\n\
\x20 failureThreshold: 3\n\
\x20 readinessProbe:\n\
\x20 httpGet:\n\
\x20 path: {readiness}\n\
\x20 port: metrics\n\
\x20 initialDelaySeconds: 5\n\
\x20 periodSeconds: 5\n\
\x20 failureThreshold: 2\n\
\x20 startupProbe:\n\
\x20 httpGet:\n\
\x20 path: {liveness}\n\
\x20 port: metrics\n\
\x20 failureThreshold: 30\n\
\x20 periodSeconds: 5\n",
liveness = c.health.liveness_path,
readiness = c.health.readiness_path,
));
out.push_str(&format!(
" volumeMounts:\n\
\x20 - name: config\n\
\x20 mountPath: {config_dir}\n\
\x20 readOnly: true\n",
config_dir = c.config_dir(),
));
out.push_str(
" {{- with .Values.resources }}\n\
\x20 resources:\n\
\x20 {{- toYaml . | nindent 12 }}\n\
\x20 {{- end }}\n",
);
out.push_str(&format!(
" volumes:\n\
\x20 - name: config\n\
\x20 configMap:\n\
\x20 name: {{{{ include \"{app}.fullname\" . }}}}-config\n",
));
out.push_str(
" {{- with .Values.nodeSelector }}\n\
\x20 nodeSelector:\n\
\x20 {{- toYaml . | nindent 8 }}\n\
\x20 {{- end }}\n\
\x20 {{- with .Values.affinity }}\n\
\x20 affinity:\n\
\x20 {{- toYaml . | nindent 8 }}\n\
\x20 {{- end }}\n\
\x20 {{- with .Values.tolerations }}\n\
\x20 tolerations:\n\
\x20 {{- toYaml . | nindent 8 }}\n\
\x20 {{- end }}\n",
);
out
}
fn gen_service_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
let mut out = format!(
r#"apiVersion: v1
kind: Service
metadata:
name: {{{{ include "{app}.fullname" . }}}}
labels:
{{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
type: {{{{ .Values.service.type }}}}
ports:
- port: {{{{ .Values.service.port }}}}
targetPort: metrics
protocol: TCP
name: metrics
"#,
);
for port in &c.extra_ports {
out.push_str(&format!(
" - port: {port}\n\
\x20 targetPort: {port}\n\
\x20 protocol: {proto}\n\
\x20 name: {name}\n",
port = port.port,
proto = port.protocol,
name = port.name,
));
}
out.push_str(&format!(
" selector:\n\
\x20 {{{{- include \"{app}.selectorLabels\" . | nindent 4 }}}}\n",
));
out
}
fn gen_serviceaccount_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
format!(
r#"{{{{- if .Values.serviceAccount.create -}}}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{{{ include "{app}.serviceAccountName" . }}}}
labels:
{{{{- include "{app}.labels" . | nindent 4 }}}}
{{{{- with .Values.serviceAccount.annotations }}}}
annotations:
{{{{- toYaml . | nindent 4 }}}}
{{{{- end }}}}
automountServiceAccountToken: false
{{{{- end }}}}
"#,
)
}
fn gen_configmap_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
let mut out = format!(
r#"apiVersion: v1
kind: ConfigMap
metadata:
name: {{{{ include "{app}.fullname" . }}}}-config
labels:
{{{{- include "{app}.labels" . | nindent 4 }}}}
data:
{filename}: |
{{{{- toYaml .Values.config | nindent 4 }}}}
"#,
app = app,
filename = c.config_filename(),
);
let _ = &mut out; out
}
fn gen_secret_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
let mut out = String::new();
let mut first = true;
for group in &c.secrets {
if !first {
out.push_str("---\n");
}
first = false;
let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
out.push_str(&format!(
"{{{{- if not .Values.{group}.existingSecret }}}}\n\
apiVersion: v1\n\
kind: Secret\n\
metadata:\n\
\x20 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
\x20 labels:\n\
\x20 {{{{- include \"{app}.labels\" . | nindent 4 }}}}\n\
type: Opaque\n\
data:\n",
group = group.group_name,
app = app,
helper = helper_name,
));
for env in &group.env_vars {
let key_lookup = safe_template_lookup(
&format!(".Values.{}.secretKeys", group.group_name),
&env.key_name,
);
let val_lookup =
safe_template_lookup(&format!(".Values.{}", group.group_name), &env.key_name);
out.push_str(&format!(
" {{{{ {key_lookup} }}}}: {{{{ {val_lookup} | b64enc | quote }}}}\n"
));
}
out.push_str("{{- end }}\n");
}
if c.secrets.is_empty() {
out.push_str("# No secrets defined in deployment contract\n");
}
out
}
fn gen_hpa_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
format!(
r#"{{{{- if and .Values.autoscaling.enabled (not .Values.keda.enabled) }}}}
# Standard HPA fallback -- use when KEDA operator is not installed.
# Mutually exclusive with keda.enabled (KEDA creates its own HPA).
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{{{ include "{app}.fullname" . }}}}
labels:
{{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{{{ include "{app}.fullname" . }}}}
minReplicas: {{{{ .Values.autoscaling.minReplicas }}}}
maxReplicas: {{{{ .Values.autoscaling.maxReplicas }}}}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{{{ .Values.autoscaling.targetCPUUtilizationPercentage }}}}
{{{{- end }}}}
"#,
)
}
fn gen_keda_scaledobject_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
let has_kafka_secret = c.secrets.iter().any(|g| g.group_name == "kafka");
let auth_ref = if has_kafka_secret {
format!(
" authenticationRef:\n\
\x20 name: {{{{ include \"{app}.fullname\" . }}}}-kafka-auth\n"
)
} else {
String::new()
};
format!(
r#"{{{{- if .Values.keda.enabled }}}}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: {{{{ include "{app}.fullname" . }}}}
labels:
{{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
scaleTargetRef:
name: {{{{ include "{app}.fullname" . }}}}
minReplicaCount: {{{{ .Values.keda.minReplicaCount }}}}
maxReplicaCount: {{{{ .Values.keda.maxReplicaCount }}}}
pollingInterval: {{{{ .Values.keda.pollingInterval }}}}
cooldownPeriod: {{{{ .Values.keda.cooldownPeriod }}}}
triggers:
# Kafka consumer group lag (primary scaler)
- type: kafka
{auth_ref} metadata:
bootstrapServers: {{{{ .Values.config.kafka.brokers | quote }}}}
consumerGroup: {{{{ .Values.keda.kafka.consumerGroup | default .Values.config.kafka.group_id | quote }}}}
{{{{- /* `default (index X 0)` would eagerly evaluate `index nil 0` and fail
lint when no topics are set. Use explicit conditional instead. */}}}}
{{{{- if .Values.keda.kafka.topic }}}}
topic: {{{{ .Values.keda.kafka.topic | quote }}}}
{{{{- else if .Values.config.kafka.topics }}}}
topic: {{{{ (index .Values.config.kafka.topics 0) | quote }}}}
{{{{- else }}}}
topic: ""
{{{{- end }}}}
lagThreshold: {{{{ .Values.keda.kafka.lagThreshold | quote }}}}
activationLagThreshold: {{{{ .Values.keda.kafka.activationLagThreshold | quote }}}}
saslType: scram_sha512
tls: disable
{{{{- if .Values.keda.cpu.enabled }}}}
# CPU utilisation (secondary scaler)
- type: cpu
metricType: Utilization
metadata:
value: {{{{ .Values.keda.cpu.threshold | quote }}}}
{{{{- end }}}}
{{{{- end }}}}
"#,
)
}
fn gen_keda_triggerauth_yaml(c: &DeploymentContract) -> String {
let app = &c.app_name;
let kafka_group = c.secrets.iter().find(|g| g.group_name == "kafka");
if kafka_group.is_none() {
return "# No kafka secret group -- KEDA TriggerAuthentication not generated\n".to_string();
}
let helper_name = format!("{}SecretName", to_camel_suffix("kafka"));
format!(
r#"{{{{- if .Values.keda.enabled }}}}
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: {{{{ include "{app}.fullname" . }}}}-kafka-auth
labels:
{{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
secretTargetRef:
- parameter: sasl
name: {{{{ include "{app}.{helper_name}" . }}}}
key: {{{{ .Values.kafka.secretKeys.username }}}}
- parameter: password
name: {{{{ include "{app}.{helper_name}" . }}}}
key: {{{{ .Values.kafka.secretKeys.password }}}}
{{{{- end }}}}
"#,
)
}
fn gen_notes_txt(c: &DeploymentContract) -> String {
let app = &c.app_name;
format!(
r#"{app} has been deployed.
1. Get the metrics/health endpoint:
kubectl port-forward svc/{{{{ include "{app}.fullname" . }}}} {{{{ .Values.service.port }}}}:{{{{ .Values.service.port }}}}
curl http://localhost:{{{{ .Values.service.port }}}}{liveness}
curl http://localhost:{{{{ .Values.service.port }}}}{metrics}
{{{{- if .Values.keda.enabled }}}}
2. Check KEDA autoscaling status:
kubectl get scaledobject {{{{ include "{app}.fullname" . }}}}
kubectl get hpa
{{{{- end }}}}
3. View logs:
kubectl logs -l app.kubernetes.io/name={{{{ include "{app}.name" . }}}} -f
"#,
app = app,
liveness = c.health.liveness_path,
metrics = c.health.metrics_path,
)
}