Add Rust-based Helm Chart Generator Tool

- Scaffold `helm-chart-tool` with Cargo project files.
- Implement core functionality: parse Cargo.toml, extract Kubernetes metadata, and generate Helm charts.
- Include support for deployments, services, ingress, and helper templates.
- Add README with detailed usage instructions.
- Update `.gitignore` for generated Helm charts and related artifacts.
This commit is contained in:
geoffsee
2025-08-28 08:39:54 -04:00
parent c8b3561e36
commit bfe7c04cf5
4 changed files with 826 additions and 5 deletions

80
.gitignore vendored
View File

@@ -1,8 +1,78 @@
# IDE and editor files
.idea/
.fastembed_cache/
target/
/.output.txt
.j**i?/
.vscode/
*.iml
dist
*.swp
*.swo
*~
.DS_Store
# Rust build artifacts
target/
Cargo.lock
*.pdb
# Node.js artifacts
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
.npm
.yarn-integrity
# Web frontend build outputs
dist/
.trunk/
# ML model and embedding caches
.fastembed_cache/
.hf-cache/
.cache/
# Generated Helm charts
generated-helm-chart/
helm-chart/
*.tgz
# Log files
*.log
logs/
.output.txt
# Temporary files
tmp/
temp/
.tmp/
# OS generated files
Thumbs.db
.DS_Store?
ehthumbs.db
# JetBrains AI Assistant files
.j**i?/
# Test outputs and coverage
coverage/
.nyc_output/
junit.xml
# Docker artifacts
.dockerignore.bak
# Python artifacts (if any Python tooling is used)
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.env
.venv/
# Backup files
*.bak
*.backup
*~

View File

@@ -0,0 +1,18 @@
[package]
name = "helm-chart-tool"
version = "0.1.0"
edition = "2021"
[workspace]
[[bin]]
name = "helm-chart-tool"
path = "src/main.rs"
[dependencies]
toml = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
clap = { version = "4.0", features = ["derive"] }
walkdir = "2.0"

218
helm-chart-tool/README.md Normal file
View File

@@ -0,0 +1,218 @@
# Helm Chart Tool
A Rust-based tool that automatically generates Helm charts from Cargo.toml metadata in Rust workspace projects.
## Overview
This tool scans a Rust workspace for crates containing Docker/Kubernetes metadata in their `Cargo.toml` files and generates a complete, production-ready Helm chart with deployments, services, ingress, and configuration templates.
## Features
- **Automatic Service Discovery**: Scans all `Cargo.toml` files in a workspace to find services with Kubernetes metadata
- **Complete Helm Chart Generation**: Creates Chart.yaml, values.yaml, deployment templates, service templates, ingress template, and helper templates
- **Metadata Extraction**: Uses `[package.metadata.kube]` sections from Cargo.toml files to extract:
- Docker image names
- Service ports
- Replica counts
- Service names
- **Production Ready**: Generated charts include health checks, resource limits, node selectors, affinity rules, and tolerations
- **Helm Best Practices**: Follows Helm chart conventions and passes `helm lint` validation
## Installation
Build the tool from source:
```bash
cd helm-chart-tool
cargo build --release
```
The binary will be available at `target/release/helm-chart-tool`.
## Usage
### Basic Usage
```bash
./target/release/helm-chart-tool --workspace /path/to/rust/workspace --output ./my-helm-chart
```
### Command Line Options
- `--workspace, -w PATH`: Path to the workspace root (default: `.`)
- `--output, -o PATH`: Output directory for the Helm chart (default: `./helm-chart`)
- `--name, -n NAME`: Name of the Helm chart (default: `predict-otron-9000`)
### Example
```bash
# Generate chart from current workspace
./target/release/helm-chart-tool
# Generate chart from specific workspace with custom output
./target/release/helm-chart-tool -w /path/to/my/workspace -o ./charts/my-app -n my-application
```
## Cargo.toml Metadata Format
The tool expects crates to have Kubernetes metadata in their `Cargo.toml` files:
```toml
[package]
name = "my-service"
version = "0.1.0"
# Required: Kubernetes metadata
[package.metadata.kube]
image = "ghcr.io/myorg/my-service:latest"
replicas = 1
port = 8080
# Optional: Docker Compose metadata (currently not used but parsed)
[package.metadata.compose]
image = "ghcr.io/myorg/my-service:latest"
port = 8080
```
### Required Fields
- `image`: Full Docker image name including registry and tag
- `port`: Port number the service listens on
- `replicas`: Number of replicas to deploy (optional, defaults to 1)
## Generated Chart Structure
The tool generates a complete Helm chart with the following structure:
```
helm-chart/
├── Chart.yaml # Chart metadata
├── values.yaml # Default configuration values
├── .helmignore # Files to ignore when packaging
└── templates/
├── _helpers.tpl # Template helper functions
├── ingress.yaml # Ingress configuration (optional)
├── {service}-deployment.yaml # Deployment for each service
└── {service}-service.yaml # Service for each service
```
### Generated Files
#### Chart.yaml
- Standard Helm v2 chart metadata
- Includes keywords for AI/ML applications
- Maintainer information
#### values.yaml
- Individual service configurations
- Resource limits and requests
- Service types and ports
- Node selectors, affinity, and tolerations
- Global settings and ingress configuration
#### Deployment Templates
- Kubernetes Deployment manifests
- Health checks (liveness and readiness probes)
- Resource management
- Container port configuration from metadata
- Support for node selectors, affinity, and tolerations
#### Service Templates
- Kubernetes Service manifests
- ClusterIP services by default
- Port mapping from metadata
#### Ingress Template
- Optional ingress configuration
- Disabled by default
- Configurable through values.yaml
## Example Output
When run against the predict-otron-9000 workspace, the tool generates:
```bash
$ ./target/release/helm-chart-tool --workspace .. --output ../generated-helm-chart
Parsing workspace at: ..
Output directory: ../generated-helm-chart
Chart name: predict-otron-9000
Found 4 services:
- leptos-chat: ghcr.io/geoffsee/leptos-chat:latest (port 8788)
- inference-engine: ghcr.io/geoffsee/inference-service:latest (port 8080)
- embeddings-engine: ghcr.io/geoffsee/embeddings-service:latest (port 8080)
- predict-otron-9000: ghcr.io/geoffsee/predict-otron-9000:latest (port 8080)
Helm chart generated successfully!
```
## Validation
The generated charts pass Helm validation:
```bash
$ helm lint generated-helm-chart
==> Linting generated-helm-chart
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed
```
## Deployment
Deploy the generated chart:
```bash
# Install the chart
helm install my-release ./generated-helm-chart
# Upgrade the chart
helm upgrade my-release ./generated-helm-chart
# Uninstall the chart
helm uninstall my-release
```
### Customization
Customize the deployment by modifying `values.yaml`:
```yaml
# Enable ingress
ingress:
enabled: true
className: "nginx"
hosts:
- host: my-app.example.com
# Adjust resources for a specific service
predict_otron_9000:
replicas: 3
resources:
limits:
memory: "4Gi"
cpu: "2000m"
requests:
memory: "2Gi"
cpu: "1000m"
```
## Requirements
- Rust 2021+ (for building the tool)
- Helm 3.x (for deploying the generated charts)
- Kubernetes cluster (for deployment)
## Limitations
- Currently assumes all services need health checks on `/health` endpoint
- Resource limits are hardcoded defaults (can be overridden in values.yaml)
- Ingress configuration is basic (can be customized through values.yaml)
## Contributing
1. Add new features to the tool
2. Test with various Cargo.toml metadata configurations
3. Validate generated charts with `helm lint`
4. Ensure charts deploy successfully to test clusters
## License
This tool is part of the predict-otron-9000 project and follows the same license terms.

515
helm-chart-tool/src/main.rs Normal file
View File

@@ -0,0 +1,515 @@
use anyhow::{Context, Result};
use clap::{Arg, Command};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Deserialize)]
struct CargoToml {
package: Option<Package>,
}
#[derive(Debug, Deserialize)]
struct Package {
name: String,
metadata: Option<Metadata>,
}
#[derive(Debug, Deserialize)]
struct Metadata {
kube: Option<KubeMetadata>,
compose: Option<ComposeMetadata>,
}
#[derive(Debug, Deserialize)]
struct KubeMetadata {
image: String,
replicas: Option<u32>,
port: u16,
}
#[derive(Debug, Deserialize)]
struct ComposeMetadata {
image: Option<String>,
port: Option<u16>,
}
#[derive(Debug, Clone)]
struct ServiceInfo {
name: String,
image: String,
port: u16,
replicas: u32,
}
fn main() -> Result<()> {
let matches = Command::new("helm-chart-tool")
.about("Generate Helm charts from Cargo.toml metadata")
.arg(
Arg::new("workspace")
.short('w')
.long("workspace")
.value_name("PATH")
.help("Path to the workspace root")
.default_value("."),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.value_name("PATH")
.help("Output directory for the Helm chart")
.default_value("./helm-chart"),
)
.arg(
Arg::new("chart-name")
.short('n')
.long("name")
.value_name("NAME")
.help("Name of the Helm chart")
.default_value("predict-otron-9000"),
)
.get_matches();
let workspace_path = matches.get_one::<String>("workspace").unwrap();
let output_path = matches.get_one::<String>("output").unwrap();
let chart_name = matches.get_one::<String>("chart-name").unwrap();
println!("Parsing workspace at: {}", workspace_path);
println!("Output directory: {}", output_path);
println!("Chart name: {}", chart_name);
let services = discover_services(workspace_path)?;
println!("Found {} services:", services.len());
for service in &services {
println!(" - {}: {} (port {})", service.name, service.image, service.port);
}
generate_helm_chart(output_path, chart_name, &services)?;
println!("Helm chart generated successfully!");
Ok(())
}
fn discover_services(workspace_path: &str) -> Result<Vec<ServiceInfo>> {
let workspace_root = Path::new(workspace_path);
let mut services = Vec::new();
// Find all Cargo.toml files in the workspace
for entry in WalkDir::new(workspace_root)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_name() == "Cargo.toml" && entry.path() != workspace_root.join("Cargo.toml") {
if let Ok(service_info) = parse_cargo_toml(entry.path()) {
services.push(service_info);
}
}
}
Ok(services)
}
fn parse_cargo_toml(path: &Path) -> Result<ServiceInfo> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read Cargo.toml at {:?}", path))?;
let cargo_toml: CargoToml = toml::from_str(&content)
.with_context(|| format!("Failed to parse Cargo.toml at {:?}", path))?;
let package = cargo_toml.package
.ok_or_else(|| anyhow::anyhow!("No package section found in {:?}", path))?;
let metadata = package.metadata
.ok_or_else(|| anyhow::anyhow!("No metadata section found in {:?}", path))?;
let kube_metadata = metadata.kube
.ok_or_else(|| anyhow::anyhow!("No kube metadata found in {:?}", path))?;
Ok(ServiceInfo {
name: package.name,
image: kube_metadata.image,
port: kube_metadata.port,
replicas: kube_metadata.replicas.unwrap_or(1),
})
}
fn generate_helm_chart(output_path: &str, chart_name: &str, services: &[ServiceInfo]) -> Result<()> {
let chart_dir = Path::new(output_path);
let templates_dir = chart_dir.join("templates");
// Create directories
fs::create_dir_all(&templates_dir)?;
// Generate Chart.yaml
generate_chart_yaml(chart_dir, chart_name)?;
// Generate values.yaml
generate_values_yaml(chart_dir, services)?;
// Generate templates for each service
for service in services {
generate_deployment_template(&templates_dir, service)?;
generate_service_template(&templates_dir, service)?;
}
// Generate ingress template
generate_ingress_template(&templates_dir, services)?;
// Generate helper templates
generate_helpers_template(&templates_dir)?;
// Generate .helmignore
generate_helmignore(chart_dir)?;
Ok(())
}
fn generate_chart_yaml(chart_dir: &Path, chart_name: &str) -> Result<()> {
let chart_yaml = format!(
r#"apiVersion: v2
name: {}
description: A Helm chart for the predict-otron-9000 AI platform
type: application
version: 0.1.0
appVersion: "0.1.0"
keywords:
- ai
- llm
- inference
- embeddings
- chat
maintainers:
- name: predict-otron-9000-team
"#,
chart_name
);
fs::write(chart_dir.join("Chart.yaml"), chart_yaml)?;
Ok(())
}
fn generate_values_yaml(chart_dir: &Path, services: &[ServiceInfo]) -> Result<()> {
let mut values = String::from(
r#"# Default values for predict-otron-9000
# This is a YAML-formatted file.
global:
imagePullPolicy: IfNotPresent
serviceType: ClusterIP
# Ingress configuration
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: predict-otron-9000.local
paths:
- path: /
pathType: Prefix
backend:
service:
name: predict-otron-9000
port:
number: 8080
tls: []
"#,
);
for service in services {
let service_config = format!(
r#"{}:
image:
repository: {}
tag: "latest"
pullPolicy: IfNotPresent
replicas: {}
service:
type: ClusterIP
port: {}
resources:
limits:
memory: "1Gi"
cpu: "1000m"
requests:
memory: "512Mi"
cpu: "250m"
nodeSelector: {{}}
tolerations: []
affinity: {{}}
"#,
service.name.replace("-", "_"),
service.image.split(':').next().unwrap_or(&service.image),
service.replicas,
service.port
);
values.push_str(&service_config);
}
fs::write(chart_dir.join("values.yaml"), values)?;
Ok(())
}
fn generate_deployment_template(templates_dir: &Path, service: &ServiceInfo) -> Result<()> {
let service_name_underscore = service.name.replace("-", "_");
let deployment_template = format!(
r#"apiVersion: apps/v1
kind: Deployment
metadata:
name: {{{{ include "predict-otron-9000.fullname" . }}}}-{}
labels:
{{{{- include "predict-otron-9000.labels" . | nindent 4 }}}}
app.kubernetes.io/component: {}
spec:
replicas: {{{{ .Values.{}.replicas }}}}
selector:
matchLabels:
{{{{- include "predict-otron-9000.selectorLabels" . | nindent 6 }}}}
app.kubernetes.io/component: {}
template:
metadata:
labels:
{{{{- include "predict-otron-9000.selectorLabels" . | nindent 8 }}}}
app.kubernetes.io/component: {}
spec:
containers:
- name: {}
image: "{{{{ .Values.{}.image.repository }}}}:{{{{ .Values.{}.image.tag }}}}"
imagePullPolicy: {{{{ .Values.{}.image.pullPolicy }}}}
ports:
- name: http
containerPort: {}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{{{- toYaml .Values.{}.resources | nindent 12 }}}}
{{{{- with .Values.{}.nodeSelector }}}}
nodeSelector:
{{{{- toYaml . | nindent 8 }}}}
{{{{- end }}}}
{{{{- with .Values.{}.affinity }}}}
affinity:
{{{{- toYaml . | nindent 8 }}}}
{{{{- end }}}}
{{{{- with .Values.{}.tolerations }}}}
tolerations:
{{{{- toYaml . | nindent 8 }}}}
{{{{- end }}}}
"#,
service.name,
service.name,
service_name_underscore,
service.name,
service.name,
service.name,
service_name_underscore,
service_name_underscore,
service_name_underscore,
service.port,
service_name_underscore,
service_name_underscore,
service_name_underscore,
service_name_underscore
);
let filename = format!("{}-deployment.yaml", service.name);
fs::write(templates_dir.join(filename), deployment_template)?;
Ok(())
}
fn generate_service_template(templates_dir: &Path, service: &ServiceInfo) -> Result<()> {
let service_template = format!(
r#"apiVersion: v1
kind: Service
metadata:
name: {{{{ include "predict-otron-9000.fullname" . }}}}-{}
labels:
{{{{- include "predict-otron-9000.labels" . | nindent 4 }}}}
app.kubernetes.io/component: {}
spec:
type: {{{{ .Values.{}.service.type }}}}
ports:
- port: {{{{ .Values.{}.service.port }}}}
targetPort: http
protocol: TCP
name: http
selector:
{{{{- include "predict-otron-9000.selectorLabels" . | nindent 4 }}}}
app.kubernetes.io/component: {}
"#,
service.name,
service.name,
service.name.replace("-", "_"),
service.name.replace("-", "_"),
service.name
);
let filename = format!("{}-service.yaml", service.name);
fs::write(templates_dir.join(filename), service_template)?;
Ok(())
}
fn generate_ingress_template(templates_dir: &Path, services: &[ServiceInfo]) -> Result<()> {
let ingress_template = r#"{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "predict-otron-9000.fullname" . }}
labels:
{{- include "predict-otron-9000.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if .pathType }}
pathType: {{ .pathType }}
{{- end }}
backend:
service:
name: {{ include "predict-otron-9000.fullname" $ }}-{{ .backend.service.name }}
port:
number: {{ .backend.service.port.number }}
{{- end }}
{{- end }}
{{- end }}
"#;
fs::write(templates_dir.join("ingress.yaml"), ingress_template)?;
Ok(())
}
fn generate_helpers_template(templates_dir: &Path) -> Result<()> {
let helpers_template = r#"{{/*
Expand the name of the chart.
*/}}
{{- define "predict-otron-9000.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "predict-otron-9000.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 "predict-otron-9000.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "predict-otron-9000.labels" -}}
helm.sh/chart: {{ include "predict-otron-9000.chart" . }}
{{ include "predict-otron-9000.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "predict-otron-9000.selectorLabels" -}}
app.kubernetes.io/name: {{ include "predict-otron-9000.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "predict-otron-9000.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "predict-otron-9000.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
"#;
fs::write(templates_dir.join("_helpers.tpl"), helpers_template)?;
Ok(())
}
fn generate_helmignore(chart_dir: &Path) -> Result<()> {
let helmignore_content = r#"# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
"#;
fs::write(chart_dir.join(".helmignore"), helmignore_content)?;
Ok(())
}