feat(skills): add helm-chart-builder agent skill
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
engineering/helm-chart-builder/.claude-plugin/plugin.json
Normal file
13
engineering/helm-chart-builder/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "helm-chart-builder",
|
||||
"description": "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw — chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Alireza Rezvani",
|
||||
"url": "https://alirezarezvani.com"
|
||||
},
|
||||
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering/helm-chart-builder",
|
||||
"repository": "https://github.com/alirezarezvani/claude-skills",
|
||||
"license": "MIT",
|
||||
"skills": "./"
|
||||
}
|
||||
449
engineering/helm-chart-builder/SKILL.md
Normal file
449
engineering/helm-chart-builder/SKILL.md
Normal file
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: "helm-chart-builder"
|
||||
description: "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw — chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing. Use when: user wants to create or improve Helm charts, design values.yaml files, implement template helpers, audit chart security (RBAC, network policies, pod security), manage subcharts, or run helm lint/test."
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Alireza Rezvani
|
||||
category: engineering
|
||||
updated: 2026-03-15
|
||||
---
|
||||
|
||||
# Helm Chart Builder
|
||||
|
||||
> Production-grade Helm charts. Sensible defaults. Secure by design. No cargo-culting.
|
||||
|
||||
Opinionated Helm workflow that turns ad-hoc Kubernetes manifests into maintainable, testable, reusable charts. Covers chart structure, values design, template patterns, dependency management, and security hardening.
|
||||
|
||||
Not a Helm tutorial — a set of concrete decisions about how to build charts that operators trust and developers don't fight.
|
||||
|
||||
---
|
||||
|
||||
## Slash Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `/helm:create` | Scaffold a production-ready Helm chart with best-practice structure |
|
||||
| `/helm:review` | Analyze an existing chart for issues — missing labels, hardcoded values, template anti-patterns |
|
||||
| `/helm:security` | Audit chart for security issues — RBAC, network policies, pod security, secrets handling |
|
||||
|
||||
---
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
Recognize these patterns from the user:
|
||||
|
||||
- "Create a Helm chart for this service"
|
||||
- "Review my Helm chart"
|
||||
- "Is this chart secure?"
|
||||
- "Design a values.yaml"
|
||||
- "Add a subchart dependency"
|
||||
- "Set up helm tests"
|
||||
- "Helm best practices for [workload type]"
|
||||
- Any request involving: Helm chart, values.yaml, Chart.yaml, templates, helpers, _helpers.tpl, subcharts, helm lint, helm test
|
||||
|
||||
If the user has a Helm chart or wants to package Kubernetes resources → this skill applies.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### `/helm:create` — Chart Scaffolding
|
||||
|
||||
1. **Identify workload type**
|
||||
- Web service (Deployment + Service + Ingress)
|
||||
- Worker (Deployment, no Service)
|
||||
- CronJob (CronJob + ServiceAccount)
|
||||
- Stateful service (StatefulSet + PVC + Headless Service)
|
||||
- Library chart (no templates, only helpers)
|
||||
|
||||
2. **Scaffold chart structure**
|
||||
|
||||
```
|
||||
mychart/
|
||||
├── Chart.yaml # Chart metadata and dependencies
|
||||
├── values.yaml # Default configuration
|
||||
├── values.schema.json # Optional: JSON Schema for values validation
|
||||
├── .helmignore # Files to exclude from packaging
|
||||
├── templates/
|
||||
│ ├── _helpers.tpl # Named templates and helper functions
|
||||
│ ├── deployment.yaml # Workload resource
|
||||
│ ├── service.yaml # Service exposure
|
||||
│ ├── ingress.yaml # Ingress (if applicable)
|
||||
│ ├── serviceaccount.yaml # ServiceAccount
|
||||
│ ├── hpa.yaml # HorizontalPodAutoscaler
|
||||
│ ├── pdb.yaml # PodDisruptionBudget
|
||||
│ ├── networkpolicy.yaml # NetworkPolicy
|
||||
│ ├── configmap.yaml # ConfigMap (if needed)
|
||||
│ ├── secret.yaml # Secret (if needed)
|
||||
│ ├── NOTES.txt # Post-install usage instructions
|
||||
│ └── tests/
|
||||
│ └── test-connection.yaml
|
||||
└── charts/ # Subcharts (dependencies)
|
||||
```
|
||||
|
||||
3. **Apply Chart.yaml best practices**
|
||||
|
||||
```
|
||||
METADATA
|
||||
├── apiVersion: v2 (Helm 3 only — never v1)
|
||||
├── name: matches directory name exactly
|
||||
├── version: semver (chart version, not app version)
|
||||
├── appVersion: application version string
|
||||
├── description: one-line summary of what the chart deploys
|
||||
└── type: application (or library for shared helpers)
|
||||
|
||||
DEPENDENCIES
|
||||
├── Pin dependency versions with ~X.Y.Z (patch-level float)
|
||||
├── Use condition field to make subcharts optional
|
||||
├── Use alias for multiple instances of same subchart
|
||||
└── Run helm dependency update after changes
|
||||
```
|
||||
|
||||
4. **Generate values.yaml with documentation**
|
||||
- Every value has an inline comment explaining purpose and type
|
||||
- Sensible defaults that work for development
|
||||
- Override-friendly structure (flat where possible, nested only when logical)
|
||||
- No hardcoded cluster-specific values (image registry, domain, storage class)
|
||||
|
||||
5. **Validate**
|
||||
```bash
|
||||
python3 scripts/chart_analyzer.py mychart/
|
||||
helm lint mychart/
|
||||
helm template mychart/ --debug
|
||||
```
|
||||
|
||||
### `/helm:review` — Chart Analysis
|
||||
|
||||
1. **Check chart structure**
|
||||
|
||||
| Check | Severity | Fix |
|
||||
|-------|----------|-----|
|
||||
| Missing _helpers.tpl | High | Create helpers for common labels and selectors |
|
||||
| No NOTES.txt | Medium | Add post-install instructions |
|
||||
| No .helmignore | Low | Create one to exclude .git, CI files, tests |
|
||||
| Missing Chart.yaml fields | Medium | Add description, appVersion, maintainers |
|
||||
| Hardcoded values in templates | High | Extract to values.yaml with defaults |
|
||||
|
||||
2. **Check template quality**
|
||||
|
||||
| Check | Severity | Fix |
|
||||
|-------|----------|-----|
|
||||
| Missing standard labels | High | Use `app.kubernetes.io/*` labels via _helpers.tpl |
|
||||
| No resource requests/limits | Critical | Add resources section with defaults in values.yaml |
|
||||
| Hardcoded image tag | High | Use `{{ .Values.image.repository }}:{{ .Values.image.tag }}` |
|
||||
| No imagePullPolicy | Medium | Default to `IfNotPresent`, overridable |
|
||||
| Missing liveness/readiness probes | High | Add probes with configurable paths and ports |
|
||||
| No pod anti-affinity | Medium | Add preferred anti-affinity for HA |
|
||||
| Duplicate template code | Medium | Extract into named templates in _helpers.tpl |
|
||||
|
||||
3. **Check values.yaml quality**
|
||||
```bash
|
||||
python3 scripts/values_validator.py mychart/values.yaml
|
||||
```
|
||||
|
||||
4. **Generate review report**
|
||||
```
|
||||
HELM CHART REVIEW — [chart name]
|
||||
Date: [timestamp]
|
||||
|
||||
CRITICAL: [count]
|
||||
HIGH: [count]
|
||||
MEDIUM: [count]
|
||||
LOW: [count]
|
||||
|
||||
[Detailed findings with fix recommendations]
|
||||
```
|
||||
|
||||
### `/helm:security` — Security Audit
|
||||
|
||||
1. **Pod security audit**
|
||||
|
||||
| Check | Severity | Fix |
|
||||
|-------|----------|-----|
|
||||
| No securityContext | Critical | Add runAsNonRoot, readOnlyRootFilesystem |
|
||||
| Running as root | Critical | Set `runAsNonRoot: true`, `runAsUser: 1000` |
|
||||
| Writable root filesystem | High | Set `readOnlyRootFilesystem: true` + emptyDir for tmp |
|
||||
| All capabilities retained | High | Drop ALL, add only specific needed caps |
|
||||
| Privileged container | Critical | Set `privileged: false`, use specific capabilities |
|
||||
| No seccomp profile | Medium | Set `seccompProfile.type: RuntimeDefault` |
|
||||
| allowPrivilegeEscalation true | High | Set `allowPrivilegeEscalation: false` |
|
||||
|
||||
2. **RBAC audit**
|
||||
|
||||
| Check | Severity | Fix |
|
||||
|-------|----------|-----|
|
||||
| No ServiceAccount | Medium | Create dedicated SA, don't use default |
|
||||
| automountServiceAccountToken true | Medium | Set to false unless pod needs K8s API access |
|
||||
| ClusterRole instead of Role | Medium | Use namespace-scoped Role unless cluster-wide needed |
|
||||
| Wildcard permissions | Critical | Use specific resource names and verbs |
|
||||
| No RBAC at all | Low | Acceptable if pod doesn't need K8s API access |
|
||||
|
||||
3. **Network and secrets audit**
|
||||
|
||||
| Check | Severity | Fix |
|
||||
|-------|----------|-----|
|
||||
| No NetworkPolicy | Medium | Add default-deny ingress + explicit allow rules |
|
||||
| Secrets in values.yaml | Critical | Use external secrets operator or sealed-secrets |
|
||||
| No PodDisruptionBudget | Medium | Add PDB with minAvailable for HA workloads |
|
||||
| hostNetwork: true | High | Remove unless absolutely required (e.g., CNI plugin) |
|
||||
| hostPID or hostIPC | Critical | Never use in application charts |
|
||||
|
||||
4. **Generate security report**
|
||||
```
|
||||
SECURITY AUDIT — [chart name]
|
||||
Date: [timestamp]
|
||||
|
||||
CRITICAL: [count]
|
||||
HIGH: [count]
|
||||
MEDIUM: [count]
|
||||
LOW: [count]
|
||||
|
||||
[Detailed findings with remediation steps]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tooling
|
||||
|
||||
### `scripts/chart_analyzer.py`
|
||||
|
||||
CLI utility for static analysis of Helm chart directories.
|
||||
|
||||
**Features:**
|
||||
- Chart structure validation (required files, directory layout)
|
||||
- Template anti-pattern detection (hardcoded values, missing labels, no resource limits)
|
||||
- Chart.yaml metadata checks
|
||||
- Standard labels verification (app.kubernetes.io/*)
|
||||
- Security baseline checks
|
||||
- JSON and text output
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Analyze a chart directory
|
||||
python3 scripts/chart_analyzer.py mychart/
|
||||
|
||||
# JSON output
|
||||
python3 scripts/chart_analyzer.py mychart/ --output json
|
||||
|
||||
# Security-focused analysis
|
||||
python3 scripts/chart_analyzer.py mychart/ --security
|
||||
```
|
||||
|
||||
### `scripts/values_validator.py`
|
||||
|
||||
CLI utility for validating values.yaml against best practices.
|
||||
|
||||
**Features:**
|
||||
- Documentation coverage (inline comments)
|
||||
- Type consistency checks
|
||||
- Hardcoded secrets detection
|
||||
- Default value quality analysis
|
||||
- Structure depth analysis
|
||||
- Naming convention validation
|
||||
- JSON and text output
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Validate values.yaml
|
||||
python3 scripts/values_validator.py values.yaml
|
||||
|
||||
# JSON output
|
||||
python3 scripts/values_validator.py values.yaml --output json
|
||||
|
||||
# Strict mode (fail on warnings)
|
||||
python3 scripts/values_validator.py values.yaml --strict
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Template Patterns
|
||||
|
||||
### Pattern 1: Standard Labels (_helpers.tpl)
|
||||
|
||||
```yaml
|
||||
{{/*
|
||||
Common labels for all resources.
|
||||
*/}}
|
||||
{{- define "mychart.labels" -}}
|
||||
helm.sh/chart: {{ include "mychart.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "mychart.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels (subset of common labels — must be immutable).
|
||||
*/}}
|
||||
{{- define "mychart.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "mychart.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### Pattern 2: Conditional Resources
|
||||
|
||||
```yaml
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "mychart.fullname" . }}
|
||||
labels:
|
||||
{{- include "mychart.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- 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 }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "mychart.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### Pattern 3: Security-Hardened Pod Spec
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
serviceAccountName: {{ include "mychart.serviceAccountName" . }}
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 8 }}
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Values Design Principles
|
||||
|
||||
```
|
||||
STRUCTURE
|
||||
├── Flat over nested (image.tag > container.spec.image.tag)
|
||||
├── Group by resource (service.*, ingress.*, resources.*)
|
||||
├── Use enabled: true/false for optional resources
|
||||
├── Document every key with inline YAML comments
|
||||
└── Provide sensible development defaults
|
||||
|
||||
NAMING
|
||||
├── camelCase for keys (replicaCount, not replica_count)
|
||||
├── Boolean keys: use adjectives (enabled, required) not verbs
|
||||
├── Nested keys: max 3 levels deep
|
||||
└── Match upstream conventions (image.repository, image.tag, image.pullPolicy)
|
||||
|
||||
ANTI-PATTERNS
|
||||
├── Hardcoded cluster URLs or domains
|
||||
├── Secrets as default values
|
||||
├── Empty strings where null is correct
|
||||
├── Deeply nested structures (>3 levels)
|
||||
├── Undocumented values
|
||||
└── values.yaml that doesn't work without overrides
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Management
|
||||
|
||||
```
|
||||
SUBCHARTS
|
||||
├── Use Chart.yaml dependencies (not requirements.yaml — Helm 3)
|
||||
├── Pin versions: version: ~15.x.x (patch float)
|
||||
├── Use condition: to make optional: condition: postgresql.enabled
|
||||
├── Use alias: for multiple instances of same chart
|
||||
├── Override subchart values under subchart name key in values.yaml
|
||||
└── Run helm dependency update before packaging
|
||||
|
||||
LIBRARY CHARTS
|
||||
├── type: library in Chart.yaml — no templates directory
|
||||
├── Export named templates only — no rendered resources
|
||||
├── Use for shared labels, annotations, security contexts
|
||||
└── Version independently from application charts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proactive Triggers
|
||||
|
||||
Flag these without being asked:
|
||||
|
||||
- **No _helpers.tpl** → Create one. Every chart needs standard labels and fullname helpers.
|
||||
- **Hardcoded image tag in template** → Extract to values.yaml. Tags must be overridable.
|
||||
- **No resource requests/limits** → Add them. Pods without limits can starve the node.
|
||||
- **Running as root** → Add securityContext. No exceptions for production charts.
|
||||
- **No NOTES.txt** → Create one. Users need post-install instructions.
|
||||
- **Secrets in values.yaml defaults** → Remove them. Use placeholders with comments explaining how to provide secrets.
|
||||
- **No liveness/readiness probes** → Add them. Kubernetes needs to know if the pod is healthy.
|
||||
- **Missing app.kubernetes.io labels** → Add via _helpers.tpl. Required for proper resource tracking.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### One-liner (any tool)
|
||||
```bash
|
||||
git clone https://github.com/alirezarezvani/claude-skills.git
|
||||
cp -r claude-skills/engineering/helm-chart-builder ~/.claude/skills/
|
||||
```
|
||||
|
||||
### Multi-tool install
|
||||
```bash
|
||||
./scripts/convert.sh --skill helm-chart-builder --tool codex|gemini|cursor|windsurf|openclaw
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
```bash
|
||||
clawhub install cs-helm-chart-builder
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **senior-devops** — Broader DevOps scope (CI/CD, IaC, monitoring). Complementary — use helm-chart-builder for chart-specific work, senior-devops for pipeline and infrastructure.
|
||||
- **docker-development** — Container building. Complementary — docker-development builds the images, helm-chart-builder deploys them to Kubernetes.
|
||||
- **ci-cd-pipeline-builder** — Pipeline construction. Complementary — helm-chart-builder defines the deployment artifact, ci-cd-pipeline-builder automates its delivery.
|
||||
- **senior-security** — Application security. Complementary — helm-chart-builder covers Kubernetes-level security (RBAC, pod security), senior-security covers application-level threats.
|
||||
435
engineering/helm-chart-builder/references/chart-patterns.md
Normal file
435
engineering/helm-chart-builder/references/chart-patterns.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Helm Chart Patterns Reference
|
||||
|
||||
## Standard Chart Structure
|
||||
|
||||
### Minimal Production Chart
|
||||
|
||||
```
|
||||
mychart/
|
||||
├── Chart.yaml
|
||||
├── values.yaml
|
||||
├── .helmignore
|
||||
└── templates/
|
||||
├── _helpers.tpl
|
||||
├── deployment.yaml
|
||||
├── service.yaml
|
||||
├── serviceaccount.yaml
|
||||
├── NOTES.txt
|
||||
└── tests/
|
||||
└── test-connection.yaml
|
||||
```
|
||||
|
||||
### Full Production Chart
|
||||
|
||||
```
|
||||
mychart/
|
||||
├── Chart.yaml
|
||||
├── values.yaml
|
||||
├── values.schema.json # JSON Schema validation
|
||||
├── .helmignore
|
||||
├── templates/
|
||||
│ ├── _helpers.tpl
|
||||
│ ├── deployment.yaml
|
||||
│ ├── service.yaml
|
||||
│ ├── ingress.yaml
|
||||
│ ├── serviceaccount.yaml
|
||||
│ ├── hpa.yaml
|
||||
│ ├── pdb.yaml
|
||||
│ ├── networkpolicy.yaml
|
||||
│ ├── configmap.yaml
|
||||
│ ├── secret.yaml
|
||||
│ ├── NOTES.txt
|
||||
│ └── tests/
|
||||
│ └── test-connection.yaml
|
||||
└── charts/ # Managed by helm dependency update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## _helpers.tpl — Standard Helpers
|
||||
|
||||
Every chart needs these. Copy and adapt.
|
||||
|
||||
```yaml
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "mychart.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
Truncated at 63 chars because some Kubernetes name fields are limited.
|
||||
*/}}
|
||||
{{- define "mychart.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 "mychart.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels.
|
||||
*/}}
|
||||
{{- define "mychart.labels" -}}
|
||||
helm.sh/chart: {{ include "mychart.chart" . }}
|
||||
{{ include "mychart.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels (immutable — used in matchLabels).
|
||||
*/}}
|
||||
{{- define "mychart.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "mychart.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use.
|
||||
*/}}
|
||||
{{- define "mychart.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "mychart.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### Why These Helpers Matter
|
||||
- **Name truncation** — Kubernetes names max at 63 characters. Always trunc.
|
||||
- **Selector labels separate from common labels** — selectors are immutable after creation. Adding `app.kubernetes.io/version` to selectors breaks upgrades.
|
||||
- **nameOverride vs fullnameOverride** — `nameOverride` replaces the chart name portion, `fullnameOverride` replaces everything.
|
||||
|
||||
---
|
||||
|
||||
## Deployment Patterns
|
||||
|
||||
### Standard Web Service
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "mychart.fullname" . }}
|
||||
labels:
|
||||
{{- include "mychart.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "mychart.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "mychart.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "mychart.serviceAccountName" . }}
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### Worker (No Service)
|
||||
|
||||
```yaml
|
||||
# Same as above but without ports, probes, or Service resource
|
||||
# Use for background workers, queue consumers, cron jobs
|
||||
spec:
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
command: {{ toYaml .Values.command | nindent 8 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conditional Resource Patterns
|
||||
|
||||
### Optional Ingress
|
||||
|
||||
```yaml
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "mychart.fullname" . }}
|
||||
labels:
|
||||
{{- include "mychart.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- 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 }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "mychart.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
### Optional HPA
|
||||
|
||||
```yaml
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "mychart.fullname" . }}
|
||||
labels:
|
||||
{{- include "mychart.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "mychart.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PodDisruptionBudget
|
||||
|
||||
```yaml
|
||||
{{- if .Values.pdb.enabled }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "mychart.fullname" . }}
|
||||
labels:
|
||||
{{- include "mychart.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if .Values.pdb.minAvailable }}
|
||||
minAvailable: {{ .Values.pdb.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .Values.pdb.maxUnavailable }}
|
||||
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "mychart.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Connection Template
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ include "mychart.fullname" . }}-test-connection"
|
||||
labels:
|
||||
{{- include "mychart.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": test
|
||||
spec:
|
||||
containers:
|
||||
- name: wget
|
||||
image: busybox
|
||||
command: ['wget']
|
||||
args: ['{{ include "mychart.fullname" . }}:{{ .Values.service.port }}']
|
||||
restartPolicy: Never
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NOTES.txt Pattern
|
||||
|
||||
```
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mychart.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
kubectl get --namespace {{ .Release.Namespace }} svc {{ include "mychart.fullname" . }} -w
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "mychart.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
|
||||
echo "Visit http://127.0.0.1:{{ .Values.service.port }}"
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Management
|
||||
|
||||
### Chart.yaml with Dependencies
|
||||
|
||||
```yaml
|
||||
apiVersion: v2
|
||||
name: myapp
|
||||
version: 1.0.0
|
||||
appVersion: "2.5.0"
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: ~15.5.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: postgresql.enabled
|
||||
- name: redis
|
||||
version: ~19.0.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
condition: redis.enabled
|
||||
- name: common
|
||||
version: ~2.0.0
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
tags:
|
||||
- bitnami-common
|
||||
```
|
||||
|
||||
### Overriding Subchart Values
|
||||
|
||||
```yaml
|
||||
# values.yaml — subchart values go under the dependency name
|
||||
postgresql:
|
||||
enabled: true
|
||||
auth:
|
||||
database: myapp
|
||||
username: myapp
|
||||
primary:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
|
||||
redis:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Download dependencies
|
||||
helm dependency update mychart/
|
||||
|
||||
# List dependencies
|
||||
helm dependency list mychart/
|
||||
|
||||
# Build (same as update but doesn't update Chart.lock)
|
||||
helm dependency build mychart/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| Template renders empty | Missing `{{- if }}` or wrong value path | `helm template --debug` to see rendered output |
|
||||
| Upgrade fails on selector change | Selector labels changed between versions | Never change selectorLabels — they're immutable |
|
||||
| Values not applying | Wrong nesting in values override | Check indentation and key paths |
|
||||
| Subchart not rendering | Missing `condition:` or dependency not updated | Run `helm dependency update` |
|
||||
| Name too long | Kubernetes 63-char limit | Ensure `trunc 63` in _helpers.tpl |
|
||||
| RBAC permission denied | ServiceAccount missing or wrong Role | Check SA exists and RoleBinding is correct |
|
||||
462
engineering/helm-chart-builder/references/values-design.md
Normal file
462
engineering/helm-chart-builder/references/values-design.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# Values.yaml Design Reference
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Every Value Is Documented
|
||||
|
||||
```yaml
|
||||
# Bad — what does this mean?
|
||||
replicaCount: 1
|
||||
maxSurge: 25%
|
||||
|
||||
# Good — clear purpose, type, and constraints
|
||||
# -- Number of pod replicas. Ignored when autoscaling.enabled is true.
|
||||
replicaCount: 1
|
||||
# -- Maximum number of pods above desired count during rolling update (int or percentage).
|
||||
maxSurge: 25%
|
||||
```
|
||||
|
||||
### 2. Sensible Defaults That Work
|
||||
|
||||
A user should be able to `helm install mychart .` with zero overrides and get a working deployment.
|
||||
|
||||
```yaml
|
||||
# Bad — broken without override
|
||||
image:
|
||||
repository: "" # Fails: no image
|
||||
tag: "" # Fails: no tag
|
||||
|
||||
# Good — works out of the box
|
||||
image:
|
||||
repository: nginx # Default image for development
|
||||
tag: "" # Defaults to .Chart.AppVersion in template
|
||||
pullPolicy: IfNotPresent
|
||||
```
|
||||
|
||||
### 3. Flat Over Nested
|
||||
|
||||
```yaml
|
||||
# Bad — 5 levels deep, painful to override
|
||||
container:
|
||||
spec:
|
||||
security:
|
||||
context:
|
||||
runAsNonRoot: true
|
||||
|
||||
# Good — 2 levels, easy to override with --set
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
```
|
||||
|
||||
**Rule of thumb:** Max 3 levels of nesting. If you need more, redesign.
|
||||
|
||||
### 4. Group by Resource
|
||||
|
||||
```yaml
|
||||
# Good — grouped by Kubernetes resource
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
hosts: []
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard Values Structure
|
||||
|
||||
### Recommended Layout Order
|
||||
|
||||
```yaml
|
||||
# -- Number of pod replicas
|
||||
replicaCount: 1
|
||||
|
||||
# -- Override chart name
|
||||
nameOverride: ""
|
||||
# -- Override fully qualified app name
|
||||
fullnameOverride: ""
|
||||
|
||||
image:
|
||||
# -- Container image repository
|
||||
repository: myapp
|
||||
# -- Image pull policy
|
||||
pullPolicy: IfNotPresent
|
||||
# -- Image tag (defaults to .Chart.AppVersion)
|
||||
tag: ""
|
||||
|
||||
# -- Image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
|
||||
serviceAccount:
|
||||
# -- Create a ServiceAccount
|
||||
create: true
|
||||
# -- Annotations for the ServiceAccount
|
||||
annotations: {}
|
||||
# -- ServiceAccount name (generated from fullname if not set)
|
||||
name: ""
|
||||
# -- Automount the service account token
|
||||
automount: false
|
||||
|
||||
# -- Pod annotations
|
||||
podAnnotations: {}
|
||||
# -- Additional pod labels
|
||||
podLabels: {}
|
||||
|
||||
# -- Pod security context
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
# -- Container security context
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
service:
|
||||
# -- Service type
|
||||
type: ClusterIP
|
||||
# -- Service port
|
||||
port: 80
|
||||
|
||||
ingress:
|
||||
# -- Enable ingress
|
||||
enabled: false
|
||||
# -- Ingress class name
|
||||
className: ""
|
||||
# -- Ingress annotations
|
||||
annotations: {}
|
||||
# -- Ingress hosts
|
||||
hosts:
|
||||
- host: chart-example.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
# -- Ingress TLS configuration
|
||||
tls: []
|
||||
|
||||
# -- Container resource requests and limits
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
|
||||
# -- Liveness probe configuration
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
|
||||
# -- Readiness probe configuration
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
autoscaling:
|
||||
# -- Enable horizontal pod autoscaler
|
||||
enabled: false
|
||||
# -- Minimum replicas
|
||||
minReplicas: 1
|
||||
# -- Maximum replicas
|
||||
maxReplicas: 10
|
||||
# -- Target CPU utilization percentage
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# -- Target memory utilization percentage (optional)
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
|
||||
pdb:
|
||||
# -- Enable PodDisruptionBudget
|
||||
enabled: false
|
||||
# -- Minimum available pods
|
||||
minAvailable: 1
|
||||
# -- Maximum unavailable pods (alternative to minAvailable)
|
||||
# maxUnavailable: 1
|
||||
|
||||
# -- Node selector constraints
|
||||
nodeSelector: {}
|
||||
# -- Tolerations for pod scheduling
|
||||
tolerations: []
|
||||
# -- Affinity rules for pod scheduling
|
||||
affinity: {}
|
||||
|
||||
# -- Additional volumes
|
||||
volumes: []
|
||||
# -- Additional volume mounts
|
||||
volumeMounts: []
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### 1. Secrets in Default Values
|
||||
|
||||
```yaml
|
||||
# BAD — secret visible in chart package, git history, Helm release
|
||||
database:
|
||||
password: "mysecretpassword"
|
||||
apiKey: "sk-abc123"
|
||||
|
||||
# GOOD — empty defaults with documentation
|
||||
database:
|
||||
# -- Database password (required). Provide via --set or external secret.
|
||||
password: ""
|
||||
# -- API key. Use external-secrets or sealed-secrets in production.
|
||||
apiKey: ""
|
||||
```
|
||||
|
||||
### 2. Cluster-Specific Defaults
|
||||
|
||||
```yaml
|
||||
# BAD — won't work on any other cluster
|
||||
ingress:
|
||||
host: app.my-company.internal
|
||||
storageClass: gp3
|
||||
registry: 123456789.dkr.ecr.us-east-1.amazonaws.com
|
||||
|
||||
# GOOD — generic defaults
|
||||
ingress:
|
||||
host: chart-example.local
|
||||
storageClass: "" # Uses cluster default
|
||||
image:
|
||||
repository: myapp # Override for private registry
|
||||
```
|
||||
|
||||
### 3. Boolean Naming
|
||||
|
||||
```yaml
|
||||
# BAD — unclear, verb-based
|
||||
createServiceAccount: true
|
||||
doAutoScale: false
|
||||
skipTLS: true
|
||||
|
||||
# GOOD — adjective-based, consistent
|
||||
serviceAccount:
|
||||
create: true # "Is it created?" reads naturally
|
||||
autoscaling:
|
||||
enabled: false # "Is it enabled?" reads naturally
|
||||
tls:
|
||||
insecureSkipVerify: false # Matches Go/K8s convention
|
||||
```
|
||||
|
||||
### 4. Undocumented Values
|
||||
|
||||
```yaml
|
||||
# BAD — what are these? What types? What are valid options?
|
||||
foo: bar
|
||||
maxRetries: 3
|
||||
mode: advanced
|
||||
workers: 4
|
||||
|
||||
# GOOD — purpose, type, and constraints are clear
|
||||
# -- Operation mode. Options: "simple", "advanced", "debug"
|
||||
mode: advanced
|
||||
# -- Number of background worker threads (1-16)
|
||||
workers: 4
|
||||
# -- Maximum retry attempts for failed API calls
|
||||
maxRetries: 3
|
||||
```
|
||||
|
||||
### 5. Empty String vs Null
|
||||
|
||||
```yaml
|
||||
# BAD — ambiguous: is empty string intentional?
|
||||
annotations: ""
|
||||
nodeSelector: ""
|
||||
|
||||
# GOOD — null/empty map means "not set"
|
||||
annotations: {}
|
||||
nodeSelector: {}
|
||||
# Or simply omit optional values
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Override Patterns
|
||||
|
||||
### Hierarchy (lowest to highest priority)
|
||||
|
||||
1. `values.yaml` in chart
|
||||
2. Parent chart's `values.yaml` (for subcharts)
|
||||
3. `-f custom-values.yaml` (left to right, last wins)
|
||||
4. `--set key=value` (highest priority)
|
||||
|
||||
### Common Override Scenarios
|
||||
|
||||
```bash
|
||||
# Production override file
|
||||
helm install myapp . -f values-production.yaml
|
||||
|
||||
# Quick override with --set
|
||||
helm install myapp . --set replicaCount=3 --set image.tag=v2.1.0
|
||||
|
||||
# Multiple value files (last wins)
|
||||
helm install myapp . -f values-base.yaml -f values-production.yaml -f values-secrets.yaml
|
||||
```
|
||||
|
||||
### values-production.yaml Pattern
|
||||
|
||||
```yaml
|
||||
# Production overrides only — don't repeat defaults
|
||||
replicaCount: 3
|
||||
|
||||
image:
|
||||
tag: "v2.1.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 3
|
||||
maxReplicas: 20
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: app.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: app-tls
|
||||
hosts:
|
||||
- app.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Safety with values.schema.json
|
||||
|
||||
### Basic Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"required": ["replicaCount", "image"],
|
||||
"properties": {
|
||||
"replicaCount": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Number of pod replicas"
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"required": ["repository"],
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Container image repository"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Image tag"
|
||||
},
|
||||
"pullPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["Always", "IfNotPresent", "Never"],
|
||||
"description": "Image pull policy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["ClusterIP", "NodePort", "LoadBalancer"],
|
||||
"description": "Kubernetes service type"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"description": "Service port number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why Use Schema
|
||||
|
||||
- **Fails fast** — `helm install` rejects invalid values before rendering templates
|
||||
- **Documents types** — self-documenting valid options (enums, ranges)
|
||||
- **IDE support** — editors can autocomplete and validate values files
|
||||
- **CI safety** — catches typos in value overrides early
|
||||
|
||||
---
|
||||
|
||||
## Testing Values
|
||||
|
||||
### helm lint
|
||||
|
||||
```bash
|
||||
# Basic lint
|
||||
helm lint mychart/
|
||||
|
||||
# Lint with override values
|
||||
helm lint mychart/ -f values-production.yaml
|
||||
|
||||
# Lint with --set
|
||||
helm lint mychart/ --set replicaCount=0 # Should fail schema
|
||||
```
|
||||
|
||||
### helm template
|
||||
|
||||
```bash
|
||||
# Render templates locally
|
||||
helm template myrelease mychart/
|
||||
|
||||
# Render with overrides to verify
|
||||
helm template myrelease mychart/ -f values-production.yaml
|
||||
|
||||
# Debug mode (shows computed values)
|
||||
helm template myrelease mychart/ --debug
|
||||
|
||||
# Render specific template
|
||||
helm template myrelease mychart/ -s templates/deployment.yaml
|
||||
```
|
||||
|
||||
### Checklist for New Values
|
||||
|
||||
| Check | Question |
|
||||
|-------|----------|
|
||||
| Documented? | Does the key have an inline comment? |
|
||||
| Default works? | Can you helm install without overriding? |
|
||||
| Type clear? | Is it obvious if this is string, int, bool, list, map? |
|
||||
| Overridable? | Can it be set with `--set`? (avoid deeply nested) |
|
||||
| No secrets? | Are default values free of passwords/tokens? |
|
||||
| camelCase? | Does it follow Helm naming convention? |
|
||||
| Flat enough? | Is nesting 3 levels or less? |
|
||||
542
engineering/helm-chart-builder/scripts/chart_analyzer.py
Normal file
542
engineering/helm-chart-builder/scripts/chart_analyzer.py
Normal file
@@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
helm-chart-builder: Chart Analyzer
|
||||
|
||||
Static analysis of Helm chart directories for structural issues, template
|
||||
anti-patterns, missing labels, hardcoded values, and security baseline checks.
|
||||
|
||||
Usage:
|
||||
python scripts/chart_analyzer.py mychart/
|
||||
python scripts/chart_analyzer.py mychart/ --output json
|
||||
python scripts/chart_analyzer.py mychart/ --security
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# --- Analysis Rules ---
|
||||
|
||||
REQUIRED_FILES = [
|
||||
{"path": "Chart.yaml", "severity": "critical", "message": "Missing Chart.yaml — not a valid Helm chart"},
|
||||
{"path": "values.yaml", "severity": "high", "message": "Missing values.yaml — chart has no configurable defaults"},
|
||||
{"path": "templates/_helpers.tpl", "severity": "high", "message": "Missing _helpers.tpl — no shared label/name helpers"},
|
||||
{"path": "templates/NOTES.txt", "severity": "medium", "message": "Missing NOTES.txt — no post-install instructions for users"},
|
||||
{"path": ".helmignore", "severity": "low", "message": "Missing .helmignore — CI files, .git, tests may be packaged"},
|
||||
]
|
||||
|
||||
CHART_YAML_CHECKS = [
|
||||
{"field": "apiVersion", "severity": "critical", "message": "Missing apiVersion in Chart.yaml"},
|
||||
{"field": "name", "severity": "critical", "message": "Missing name in Chart.yaml"},
|
||||
{"field": "version", "severity": "critical", "message": "Missing version in Chart.yaml"},
|
||||
{"field": "description", "severity": "medium", "message": "Missing description in Chart.yaml"},
|
||||
{"field": "appVersion", "severity": "medium", "message": "Missing appVersion in Chart.yaml — operators won't know what app version is deployed"},
|
||||
{"field": "type", "severity": "low", "message": "Missing type in Chart.yaml — defaults to 'application'"},
|
||||
]
|
||||
|
||||
TEMPLATE_ANTI_PATTERNS = [
|
||||
{
|
||||
"id": "TP001",
|
||||
"severity": "high",
|
||||
"pattern": r'image:\s*["\']?[a-z][a-z0-9./-]+:[a-z0-9][a-z0-9._-]*["\']?\s*$',
|
||||
"message": "Hardcoded image tag in template — must use .Values.image.repository and .Values.image.tag",
|
||||
"fix": 'Use: image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"',
|
||||
},
|
||||
{
|
||||
"id": "TP002",
|
||||
"severity": "high",
|
||||
"pattern": r'replicas:\s*\d+\s*$',
|
||||
"message": "Hardcoded replica count — must be configurable via values",
|
||||
"fix": "Use: replicas: {{ .Values.replicaCount }}",
|
||||
},
|
||||
{
|
||||
"id": "TP003",
|
||||
"severity": "medium",
|
||||
"pattern": r'port:\s*\d+\s*$',
|
||||
"message": "Hardcoded port number — should be configurable via values",
|
||||
"fix": "Use: port: {{ .Values.service.port }}",
|
||||
},
|
||||
{
|
||||
"id": "TP004",
|
||||
"severity": "high",
|
||||
"pattern": r'(?:name|namespace):\s*[a-z][a-z0-9-]+\s*$',
|
||||
"message": "Hardcoded name/namespace — should use template helpers",
|
||||
"fix": 'Use: name: {{ include "mychart.fullname" . }}',
|
||||
},
|
||||
{
|
||||
"id": "TP005",
|
||||
"severity": "medium",
|
||||
"pattern": r'nodePort:\s*\d+',
|
||||
"message": "Hardcoded nodePort — should be configurable or avoided",
|
||||
"fix": "Use: nodePort: {{ .Values.service.nodePort }} with conditional",
|
||||
},
|
||||
]
|
||||
|
||||
SECURITY_CHECKS = [
|
||||
{
|
||||
"id": "SC001",
|
||||
"severity": "critical",
|
||||
"check": "no_security_context",
|
||||
"message": "No securityContext found in any template — pods run as root with full capabilities",
|
||||
"fix": "Add pod and container securityContext with runAsNonRoot, readOnlyRootFilesystem, drop ALL capabilities",
|
||||
},
|
||||
{
|
||||
"id": "SC002",
|
||||
"severity": "critical",
|
||||
"check": "privileged_container",
|
||||
"message": "Privileged container detected — full host access",
|
||||
"fix": "Remove privileged: true. Use specific capabilities instead",
|
||||
},
|
||||
{
|
||||
"id": "SC003",
|
||||
"severity": "high",
|
||||
"check": "no_run_as_non_root",
|
||||
"message": "No runAsNonRoot: true — container may run as root",
|
||||
"fix": "Add runAsNonRoot: true to pod securityContext",
|
||||
},
|
||||
{
|
||||
"id": "SC004",
|
||||
"severity": "high",
|
||||
"check": "no_readonly_rootfs",
|
||||
"message": "No readOnlyRootFilesystem — container filesystem is writable",
|
||||
"fix": "Add readOnlyRootFilesystem: true and use emptyDir for writable paths",
|
||||
},
|
||||
{
|
||||
"id": "SC005",
|
||||
"severity": "medium",
|
||||
"check": "no_network_policy",
|
||||
"message": "No NetworkPolicy template — all pod-to-pod traffic allowed",
|
||||
"fix": "Add a NetworkPolicy template with default-deny ingress and explicit allow rules",
|
||||
},
|
||||
{
|
||||
"id": "SC006",
|
||||
"severity": "medium",
|
||||
"check": "automount_sa_token",
|
||||
"message": "automountServiceAccountToken not set to false — pod can access K8s API",
|
||||
"fix": "Set automountServiceAccountToken: false unless the pod needs K8s API access",
|
||||
},
|
||||
{
|
||||
"id": "SC007",
|
||||
"severity": "high",
|
||||
"check": "host_network",
|
||||
"message": "hostNetwork: true — pod shares host network namespace",
|
||||
"fix": "Remove hostNetwork unless absolutely required (e.g., CNI plugin)",
|
||||
},
|
||||
{
|
||||
"id": "SC008",
|
||||
"severity": "critical",
|
||||
"check": "host_pid_ipc",
|
||||
"message": "hostPID or hostIPC enabled — pod can see host processes/IPC",
|
||||
"fix": "Remove hostPID and hostIPC — never needed in application charts",
|
||||
},
|
||||
]
|
||||
|
||||
LABEL_PATTERNS = [
|
||||
r"app\.kubernetes\.io/name",
|
||||
r"app\.kubernetes\.io/instance",
|
||||
r"app\.kubernetes\.io/version",
|
||||
r"app\.kubernetes\.io/managed-by",
|
||||
r"helm\.sh/chart",
|
||||
]
|
||||
|
||||
|
||||
# --- Demo Chart ---
|
||||
|
||||
DEMO_CHART_YAML = """apiVersion: v2
|
||||
name: demo-app
|
||||
version: 0.1.0
|
||||
"""
|
||||
|
||||
DEMO_VALUES_YAML = """replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: nginx
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
"""
|
||||
|
||||
DEMO_DEPLOYMENT = """apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: demo-app
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: demo-app
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- containerPort: 80
|
||||
"""
|
||||
|
||||
|
||||
def parse_yaml_simple(content):
|
||||
"""Simple key-value parser for YAML (stdlib only)."""
|
||||
result = {}
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if ":" in stripped and not stripped.startswith("-"):
|
||||
key, _, val = stripped.partition(":")
|
||||
key = key.strip()
|
||||
val = val.strip().strip("'\"")
|
||||
if val:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def check_structure(chart_dir):
|
||||
"""Check chart directory for required files."""
|
||||
findings = []
|
||||
for check in REQUIRED_FILES:
|
||||
path = chart_dir / check["path"]
|
||||
if not path.exists():
|
||||
findings.append({
|
||||
"id": "ST" + str(REQUIRED_FILES.index(check) + 1).zfill(3),
|
||||
"severity": check["severity"],
|
||||
"message": check["message"],
|
||||
"fix": f"Create {check['path']}",
|
||||
"file": check["path"],
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def check_chart_yaml(chart_dir):
|
||||
"""Validate Chart.yaml metadata."""
|
||||
findings = []
|
||||
chart_path = chart_dir / "Chart.yaml"
|
||||
if not chart_path.exists():
|
||||
return findings
|
||||
|
||||
content = chart_path.read_text(encoding="utf-8")
|
||||
parsed = parse_yaml_simple(content)
|
||||
|
||||
for check in CHART_YAML_CHECKS:
|
||||
if check["field"] not in parsed:
|
||||
findings.append({
|
||||
"id": "CY" + str(CHART_YAML_CHECKS.index(check) + 1).zfill(3),
|
||||
"severity": check["severity"],
|
||||
"message": check["message"],
|
||||
"fix": f"Add '{check['field']}:' to Chart.yaml",
|
||||
"file": "Chart.yaml",
|
||||
})
|
||||
|
||||
# Check apiVersion value
|
||||
if parsed.get("apiVersion") == "v1":
|
||||
findings.append({
|
||||
"id": "CY007",
|
||||
"severity": "medium",
|
||||
"message": "apiVersion: v1 is Helm 2 format — use v2 for Helm 3",
|
||||
"fix": "Change apiVersion to v2",
|
||||
"file": "Chart.yaml",
|
||||
})
|
||||
|
||||
# Check version is semver
|
||||
version = parsed.get("version", "")
|
||||
if version and not re.match(r"^\d+\.\d+\.\d+", version):
|
||||
findings.append({
|
||||
"id": "CY008",
|
||||
"severity": "high",
|
||||
"message": f"Version '{version}' is not valid semver",
|
||||
"fix": "Use semver format: MAJOR.MINOR.PATCH (e.g., 1.0.0)",
|
||||
"file": "Chart.yaml",
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_templates(chart_dir):
|
||||
"""Scan templates for anti-patterns."""
|
||||
findings = []
|
||||
templates_dir = chart_dir / "templates"
|
||||
if not templates_dir.exists():
|
||||
return findings
|
||||
|
||||
template_files = list(templates_dir.glob("*.yaml")) + list(templates_dir.glob("*.yml")) + list(templates_dir.glob("*.tpl"))
|
||||
|
||||
all_content = ""
|
||||
for tpl_file in template_files:
|
||||
content = tpl_file.read_text(encoding="utf-8")
|
||||
all_content += content + "\n"
|
||||
rel_path = tpl_file.relative_to(chart_dir)
|
||||
|
||||
for rule in TEMPLATE_ANTI_PATTERNS:
|
||||
# Skip patterns that would false-positive on template expressions
|
||||
for match in re.finditer(rule["pattern"], content, re.MULTILINE):
|
||||
line = match.group(0).strip()
|
||||
# Skip if the line contains a template expression
|
||||
if "{{" in line or "}}" in line:
|
||||
continue
|
||||
findings.append({
|
||||
"id": rule["id"],
|
||||
"severity": rule["severity"],
|
||||
"message": rule["message"],
|
||||
"fix": rule["fix"],
|
||||
"file": str(rel_path),
|
||||
"line": line[:80],
|
||||
})
|
||||
|
||||
# Check for standard labels
|
||||
helpers_file = templates_dir / "_helpers.tpl"
|
||||
if helpers_file.exists():
|
||||
helpers_content = helpers_file.read_text(encoding="utf-8")
|
||||
for label_pattern in LABEL_PATTERNS:
|
||||
if not re.search(label_pattern, helpers_content) and not re.search(label_pattern, all_content):
|
||||
label_name = label_pattern.replace("\\.", ".")
|
||||
findings.append({
|
||||
"id": "LB001",
|
||||
"severity": "high",
|
||||
"message": f"Standard label '{label_name}' not found in helpers or templates",
|
||||
"fix": f"Add {label_name} to the labels helper in _helpers.tpl",
|
||||
"file": "templates/_helpers.tpl",
|
||||
"line": "(label not found)",
|
||||
})
|
||||
|
||||
# Check for resource limits
|
||||
if "resources:" not in all_content and template_files:
|
||||
findings.append({
|
||||
"id": "TP006",
|
||||
"severity": "critical",
|
||||
"message": "No resource requests/limits in any template — pods can consume unlimited node resources",
|
||||
"fix": "Add resources section: {{ toYaml .Values.resources | nindent 12 }}",
|
||||
"file": "templates/",
|
||||
"line": "(no resources block found)",
|
||||
})
|
||||
|
||||
# Check for probes
|
||||
if "livenessProbe" not in all_content and "readinessProbe" not in all_content and template_files:
|
||||
has_deployment = any("Deployment" in f.read_text(encoding="utf-8") for f in template_files if f.suffix in (".yaml", ".yml"))
|
||||
if has_deployment:
|
||||
findings.append({
|
||||
"id": "TP007",
|
||||
"severity": "high",
|
||||
"message": "No liveness/readiness probes — Kubernetes cannot detect unhealthy pods",
|
||||
"fix": "Add livenessProbe and readinessProbe with configurable values",
|
||||
"file": "templates/deployment.yaml",
|
||||
"line": "(no probes found)",
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_security(chart_dir):
|
||||
"""Run security-focused checks."""
|
||||
findings = []
|
||||
templates_dir = chart_dir / "templates"
|
||||
if not templates_dir.exists():
|
||||
return findings
|
||||
|
||||
template_files = list(templates_dir.glob("*.yaml")) + list(templates_dir.glob("*.yml"))
|
||||
all_content = ""
|
||||
for tpl_file in template_files:
|
||||
all_content += tpl_file.read_text(encoding="utf-8") + "\n"
|
||||
|
||||
for check in SECURITY_CHECKS:
|
||||
triggered = False
|
||||
|
||||
if check["check"] == "no_security_context":
|
||||
if "securityContext" not in all_content and template_files:
|
||||
triggered = True
|
||||
elif check["check"] == "privileged_container":
|
||||
if re.search(r"privileged:\s*true", all_content):
|
||||
triggered = True
|
||||
elif check["check"] == "no_run_as_non_root":
|
||||
if "securityContext" in all_content and "runAsNonRoot" not in all_content:
|
||||
triggered = True
|
||||
elif check["check"] == "no_readonly_rootfs":
|
||||
if "securityContext" in all_content and "readOnlyRootFilesystem" not in all_content:
|
||||
triggered = True
|
||||
elif check["check"] == "no_network_policy":
|
||||
np_file = templates_dir / "networkpolicy.yaml"
|
||||
if not np_file.exists() and "NetworkPolicy" not in all_content:
|
||||
triggered = True
|
||||
elif check["check"] == "automount_sa_token":
|
||||
if "automountServiceAccountToken" not in all_content and template_files:
|
||||
triggered = True
|
||||
elif check["check"] == "host_network":
|
||||
if re.search(r"hostNetwork:\s*true", all_content):
|
||||
triggered = True
|
||||
elif check["check"] == "host_pid_ipc":
|
||||
if re.search(r"host(?:PID|IPC):\s*true", all_content):
|
||||
triggered = True
|
||||
|
||||
if triggered:
|
||||
findings.append({
|
||||
"id": check["id"],
|
||||
"severity": check["severity"],
|
||||
"message": check["message"],
|
||||
"fix": check["fix"],
|
||||
"file": "templates/",
|
||||
})
|
||||
|
||||
# Check for secrets in values.yaml
|
||||
values_path = chart_dir / "values.yaml"
|
||||
if values_path.exists():
|
||||
values_content = values_path.read_text(encoding="utf-8")
|
||||
for match in re.finditer(r"^(\s*\S*(?:password|secret|token|apiKey|api_key)\s*:\s*)(\S+)", values_content, re.MULTILINE | re.IGNORECASE):
|
||||
val = match.group(2).strip("'\"")
|
||||
if val and val not in ("null", "~", '""', "''", "changeme", "CHANGEME", "TODO"):
|
||||
findings.append({
|
||||
"id": "SC009",
|
||||
"severity": "critical",
|
||||
"message": f"Potential secret in values.yaml default: {match.group(0).strip()[:60]}",
|
||||
"fix": "Remove default secret values. Use empty string or null with documentation",
|
||||
"file": "values.yaml",
|
||||
"line": match.group(0).strip()[:80],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def analyze_chart(chart_dir, output_format="text", security_focus=False):
|
||||
"""Run full chart analysis."""
|
||||
findings = []
|
||||
findings.extend(check_structure(chart_dir))
|
||||
findings.extend(check_chart_yaml(chart_dir))
|
||||
findings.extend(check_templates(chart_dir))
|
||||
|
||||
if security_focus:
|
||||
findings.extend(check_security(chart_dir))
|
||||
# Filter to security-relevant items only
|
||||
security_ids = {"SC001", "SC002", "SC003", "SC004", "SC005", "SC006", "SC007", "SC008", "SC009"}
|
||||
security_severities = {"critical", "high"}
|
||||
findings = [f for f in findings if f["id"] in security_ids or f["severity"] in security_severities]
|
||||
else:
|
||||
findings.extend(check_security(chart_dir))
|
||||
|
||||
# Deduplicate
|
||||
seen = set()
|
||||
unique = []
|
||||
for f in findings:
|
||||
key = (f["id"], f.get("line", ""), f.get("file", ""))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(f)
|
||||
findings = unique
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||
findings.sort(key=lambda f: severity_order.get(f["severity"], 4))
|
||||
|
||||
# Score
|
||||
deductions = {"critical": 25, "high": 15, "medium": 5, "low": 2}
|
||||
score = max(0, 100 - sum(deductions.get(f["severity"], 0) for f in findings))
|
||||
|
||||
counts = {
|
||||
"critical": sum(1 for f in findings if f["severity"] == "critical"),
|
||||
"high": sum(1 for f in findings if f["severity"] == "high"),
|
||||
"medium": sum(1 for f in findings if f["severity"] == "medium"),
|
||||
"low": sum(1 for f in findings if f["severity"] == "low"),
|
||||
}
|
||||
|
||||
# Chart metadata
|
||||
chart_yaml_path = chart_dir / "Chart.yaml"
|
||||
chart_meta = parse_yaml_simple(chart_yaml_path.read_text(encoding="utf-8")) if chart_yaml_path.exists() else {}
|
||||
|
||||
result = {
|
||||
"score": score,
|
||||
"chart_name": chart_meta.get("name", chart_dir.name),
|
||||
"chart_version": chart_meta.get("version", "unknown"),
|
||||
"app_version": chart_meta.get("appVersion", "unknown"),
|
||||
"findings": findings,
|
||||
"finding_counts": counts,
|
||||
}
|
||||
|
||||
if output_format == "json":
|
||||
print(json.dumps(result, indent=2))
|
||||
return result
|
||||
|
||||
# Text output
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Helm Chart Analysis Report")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Score: {score}/100")
|
||||
print(f" Chart: {result['chart_name']} v{result['chart_version']}")
|
||||
print(f" App Version: {result['app_version']}")
|
||||
print()
|
||||
print(f" Findings: {counts['critical']} critical | {counts['high']} high | {counts['medium']} medium | {counts['low']} low")
|
||||
print(f"{'─' * 60}")
|
||||
|
||||
for f in findings:
|
||||
icon = {"critical": "!!!", "high": "!!", "medium": "!", "low": "~"}.get(f["severity"], "?")
|
||||
print(f"\n [{f['id']}] {icon} {f['severity'].upper()}")
|
||||
print(f" {f['message']}")
|
||||
if "file" in f:
|
||||
print(f" File: {f['file']}")
|
||||
if "line" in f:
|
||||
print(f" Line: {f['line']}")
|
||||
print(f" Fix: {f['fix']}")
|
||||
|
||||
if not findings:
|
||||
print("\n No issues found. Chart looks good.")
|
||||
|
||||
print(f"\n{'=' * 60}\n")
|
||||
return result
|
||||
|
||||
|
||||
def run_demo():
|
||||
"""Run analysis on demo chart data."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
chart_dir = Path(tmpdir) / "demo-app"
|
||||
chart_dir.mkdir()
|
||||
(chart_dir / "Chart.yaml").write_text(DEMO_CHART_YAML)
|
||||
(chart_dir / "values.yaml").write_text(DEMO_VALUES_YAML)
|
||||
templates_dir = chart_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
(templates_dir / "deployment.yaml").write_text(DEMO_DEPLOYMENT)
|
||||
|
||||
return chart_dir, analyze_chart
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="helm-chart-builder: Helm chart static analyzer"
|
||||
)
|
||||
parser.add_argument("chartdir", nargs="?", help="Path to Helm chart directory (omit for demo)")
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--security",
|
||||
action="store_true",
|
||||
help="Security-focused analysis only",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.chartdir:
|
||||
chart_dir = Path(args.chartdir)
|
||||
if not chart_dir.is_dir():
|
||||
print(f"Error: Not a directory: {args.chartdir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
analyze_chart(chart_dir, args.output, args.security)
|
||||
else:
|
||||
print("No chart directory provided. Running demo analysis...\n")
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
chart_dir = Path(tmpdir) / "demo-app"
|
||||
chart_dir.mkdir()
|
||||
(chart_dir / "Chart.yaml").write_text(DEMO_CHART_YAML)
|
||||
(chart_dir / "values.yaml").write_text(DEMO_VALUES_YAML)
|
||||
templates_dir = chart_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
(templates_dir / "deployment.yaml").write_text(DEMO_DEPLOYMENT)
|
||||
analyze_chart(chart_dir, args.output, args.security)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
442
engineering/helm-chart-builder/scripts/values_validator.py
Normal file
442
engineering/helm-chart-builder/scripts/values_validator.py
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
helm-chart-builder: Values Validator
|
||||
|
||||
Validate values.yaml files against Helm best practices — documentation coverage,
|
||||
type consistency, naming conventions, default quality, and security.
|
||||
|
||||
Usage:
|
||||
python scripts/values_validator.py values.yaml
|
||||
python scripts/values_validator.py values.yaml --output json
|
||||
python scripts/values_validator.py values.yaml --strict
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# --- Demo values.yaml ---
|
||||
|
||||
DEMO_VALUES = """# Default values for demo-app
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: nginx
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
resources: {}
|
||||
|
||||
PASSWORD: supersecret123
|
||||
db_password: changeme
|
||||
api-key: sk-12345
|
||||
|
||||
deeply:
|
||||
nested:
|
||||
structure:
|
||||
that:
|
||||
goes:
|
||||
too:
|
||||
deep: true
|
||||
|
||||
undocumented_value: something
|
||||
AnotherValue: 42
|
||||
snake_case_key: bad
|
||||
"""
|
||||
|
||||
|
||||
# --- Validation Rules ---
|
||||
|
||||
NAMING_PATTERN = re.compile(r"^[a-z][a-zA-Z0-9]*$") # camelCase
|
||||
SNAKE_CASE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)+$") # snake_case
|
||||
UPPER_CASE_PATTERN = re.compile(r"^[A-Z]") # Starts with uppercase
|
||||
|
||||
SECRET_KEY_PATTERNS = [
|
||||
re.compile(r"(?:password|secret|token|apiKey|api_key|api-key|private_key|credentials)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
KNOWN_STRUCTURES = {
|
||||
"image": ["repository", "tag", "pullPolicy"],
|
||||
"service": ["type", "port"],
|
||||
"ingress": ["enabled"],
|
||||
"resources": [],
|
||||
"serviceAccount": ["create", "name"],
|
||||
"autoscaling": ["enabled", "minReplicas", "maxReplicas"],
|
||||
}
|
||||
|
||||
|
||||
def parse_values(content):
|
||||
"""Parse values.yaml into structured data with metadata.
|
||||
|
||||
Returns a list of entries with key paths, values, depth, and comment info.
|
||||
"""
|
||||
entries = []
|
||||
key_stack = []
|
||||
indent_stack = [0]
|
||||
prev_comment = None
|
||||
|
||||
for line_num, line in enumerate(content.splitlines(), 1):
|
||||
stripped = line.strip()
|
||||
|
||||
# Track comments for documentation coverage
|
||||
if stripped.startswith("#"):
|
||||
prev_comment = stripped
|
||||
continue
|
||||
|
||||
if not stripped:
|
||||
prev_comment = None
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Pop stack for dedented lines
|
||||
while len(indent_stack) > 1 and indent <= indent_stack[-1]:
|
||||
indent_stack.pop()
|
||||
if key_stack:
|
||||
key_stack.pop()
|
||||
|
||||
# Parse key: value
|
||||
match = re.match(r"^(\S+)\s*:\s*(.*)", stripped)
|
||||
if match and not stripped.startswith("-"):
|
||||
key = match.group(1)
|
||||
raw_value = match.group(2).strip()
|
||||
|
||||
# Check for inline comment
|
||||
inline_comment = None
|
||||
if "#" in raw_value:
|
||||
val_part, _, comment_part = raw_value.partition("#")
|
||||
raw_value = val_part.strip()
|
||||
inline_comment = comment_part.strip()
|
||||
|
||||
# Build full key path
|
||||
full_path = ".".join(key_stack + [key])
|
||||
depth = len(key_stack) + 1
|
||||
|
||||
# Determine value type
|
||||
value_type = "unknown"
|
||||
if not raw_value or raw_value == "":
|
||||
value_type = "map"
|
||||
key_stack.append(key)
|
||||
indent_stack.append(indent)
|
||||
elif raw_value in ("true", "false"):
|
||||
value_type = "boolean"
|
||||
elif raw_value == "null" or raw_value == "~":
|
||||
value_type = "null"
|
||||
elif raw_value == "{}":
|
||||
value_type = "empty_map"
|
||||
elif raw_value == "[]":
|
||||
value_type = "empty_list"
|
||||
elif re.match(r"^-?\d+$", raw_value):
|
||||
value_type = "integer"
|
||||
elif re.match(r"^-?\d+\.\d+$", raw_value):
|
||||
value_type = "float"
|
||||
elif raw_value.startswith('"') or raw_value.startswith("'"):
|
||||
value_type = "string"
|
||||
else:
|
||||
value_type = "string"
|
||||
|
||||
has_doc = prev_comment is not None or inline_comment is not None
|
||||
|
||||
entries.append({
|
||||
"key": key,
|
||||
"full_path": full_path,
|
||||
"value": raw_value,
|
||||
"value_type": value_type,
|
||||
"depth": depth,
|
||||
"line": line_num,
|
||||
"has_documentation": has_doc,
|
||||
"comment": prev_comment or inline_comment,
|
||||
})
|
||||
|
||||
prev_comment = None
|
||||
else:
|
||||
prev_comment = None
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def validate_naming(entries):
|
||||
"""Check key naming conventions."""
|
||||
findings = []
|
||||
|
||||
for entry in entries:
|
||||
key = entry["key"]
|
||||
|
||||
# Skip map entries (they're parent keys)
|
||||
if entry["value_type"] == "map":
|
||||
# Parent keys should still be camelCase
|
||||
pass
|
||||
|
||||
if SNAKE_CASE_PATTERN.match(key):
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "naming",
|
||||
"message": f"Key '{entry['full_path']}' uses snake_case — Helm convention is camelCase",
|
||||
"fix": f"Rename to camelCase: {to_camel_case(key)}",
|
||||
"line": entry["line"],
|
||||
})
|
||||
elif UPPER_CASE_PATTERN.match(key) and not key.isupper():
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "naming",
|
||||
"message": f"Key '{entry['full_path']}' starts with uppercase — use camelCase",
|
||||
"fix": f"Rename: {key[0].lower() + key[1:]}",
|
||||
"line": entry["line"],
|
||||
})
|
||||
elif "-" in key:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "naming",
|
||||
"message": f"Key '{entry['full_path']}' uses kebab-case — Helm convention is camelCase",
|
||||
"fix": f"Rename to camelCase: {to_camel_case(key)}",
|
||||
"line": entry["line"],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def validate_documentation(entries):
|
||||
"""Check documentation coverage."""
|
||||
findings = []
|
||||
total = len(entries)
|
||||
documented = sum(1 for e in entries if e["has_documentation"])
|
||||
|
||||
if total > 0:
|
||||
coverage = (documented / total) * 100
|
||||
if coverage < 50:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "documentation",
|
||||
"message": f"Only {coverage:.0f}% of values have comments ({documented}/{total})",
|
||||
"fix": "Add inline YAML comments explaining purpose, type, and valid options for each value",
|
||||
"line": 0,
|
||||
})
|
||||
elif coverage < 80:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "documentation",
|
||||
"message": f"{coverage:.0f}% documentation coverage ({documented}/{total}) — aim for 80%+",
|
||||
"fix": "Add comments for undocumented values",
|
||||
"line": 0,
|
||||
})
|
||||
|
||||
# Flag specific undocumented top-level keys
|
||||
for entry in entries:
|
||||
if entry["depth"] == 1 and not entry["has_documentation"]:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "documentation",
|
||||
"message": f"Top-level key '{entry['key']}' has no comment",
|
||||
"fix": f"Add a comment above '{entry['key']}' explaining its purpose",
|
||||
"line": entry["line"],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def validate_defaults(entries):
|
||||
"""Check default value quality."""
|
||||
findings = []
|
||||
|
||||
for entry in entries:
|
||||
# Check for :latest tag
|
||||
if entry["key"] == "tag" and entry["value"] in ("latest", '"latest"', "'latest'"):
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "defaults",
|
||||
"message": f"image.tag defaults to 'latest' — not reproducible",
|
||||
"fix": "Use a specific version tag or reference .Chart.AppVersion in template",
|
||||
"line": entry["line"],
|
||||
})
|
||||
|
||||
# Check pullPolicy
|
||||
if entry["key"] == "pullPolicy" and entry["value"] in ("Always", '"Always"', "'Always'"):
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "defaults",
|
||||
"message": "imagePullPolicy defaults to 'Always' — 'IfNotPresent' is better for production",
|
||||
"fix": "Change default to IfNotPresent (Always is appropriate for :latest only)",
|
||||
"line": entry["line"],
|
||||
})
|
||||
|
||||
# Check empty resources
|
||||
if entry["key"] == "resources" and entry["value_type"] == "empty_map":
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "defaults",
|
||||
"message": "resources defaults to {} — no requests or limits set",
|
||||
"fix": "Provide default resource requests (e.g., cpu: 100m, memory: 128Mi)",
|
||||
"line": entry["line"],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def validate_secrets(entries):
|
||||
"""Check for secrets in default values."""
|
||||
findings = []
|
||||
|
||||
for entry in entries:
|
||||
for pattern in SECRET_KEY_PATTERNS:
|
||||
if pattern.search(entry["full_path"]):
|
||||
val = entry["value"].strip("'\"")
|
||||
if val and val not in ("", "null", "~", "{}", "[]", "changeme", "CHANGEME", "TODO", '""', "''"):
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"message": f"Potential secret with default value: {entry['full_path']} = {val[:30]}...",
|
||||
"fix": "Remove default. Use empty string, null, or 'changeme' placeholder with comment",
|
||||
"line": entry["line"],
|
||||
})
|
||||
break
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def validate_depth(entries):
|
||||
"""Check nesting depth."""
|
||||
findings = []
|
||||
max_depth = max((e["depth"] for e in entries), default=0)
|
||||
|
||||
if max_depth > 4:
|
||||
deep_entries = [e for e in entries if e["depth"] > 4]
|
||||
for entry in deep_entries[:3]: # Report first 3
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"message": f"Deeply nested key ({entry['depth']} levels): {entry['full_path']}",
|
||||
"fix": "Flatten structure — max 3-4 levels deep for usability",
|
||||
"line": entry["line"],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def to_camel_case(name):
|
||||
"""Convert snake_case or kebab-case to camelCase."""
|
||||
parts = re.split(r"[-_]", name)
|
||||
return parts[0].lower() + "".join(p.capitalize() for p in parts[1:])
|
||||
|
||||
|
||||
def generate_report(content, output_format="text", strict=False):
|
||||
"""Generate full validation report."""
|
||||
entries = parse_values(content)
|
||||
findings = []
|
||||
|
||||
findings.extend(validate_naming(entries))
|
||||
findings.extend(validate_documentation(entries))
|
||||
findings.extend(validate_defaults(entries))
|
||||
findings.extend(validate_secrets(entries))
|
||||
findings.extend(validate_depth(entries))
|
||||
|
||||
if strict:
|
||||
# Elevate medium to high, low to medium
|
||||
for f in findings:
|
||||
if f["severity"] == "medium":
|
||||
f["severity"] = "high"
|
||||
elif f["severity"] == "low":
|
||||
f["severity"] = "medium"
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
||||
findings.sort(key=lambda f: severity_order.get(f["severity"], 4))
|
||||
|
||||
# Score
|
||||
deductions = {"critical": 25, "high": 15, "medium": 5, "low": 2}
|
||||
score = max(0, 100 - sum(deductions.get(f["severity"], 0) for f in findings))
|
||||
|
||||
counts = {
|
||||
"critical": sum(1 for f in findings if f["severity"] == "critical"),
|
||||
"high": sum(1 for f in findings if f["severity"] == "high"),
|
||||
"medium": sum(1 for f in findings if f["severity"] == "medium"),
|
||||
"low": sum(1 for f in findings if f["severity"] == "low"),
|
||||
}
|
||||
|
||||
# Stats
|
||||
total_keys = len(entries)
|
||||
documented = sum(1 for e in entries if e["has_documentation"])
|
||||
max_depth = max((e["depth"] for e in entries), default=0)
|
||||
|
||||
result = {
|
||||
"score": score,
|
||||
"total_keys": total_keys,
|
||||
"documented_keys": documented,
|
||||
"documentation_coverage": f"{(documented / total_keys * 100):.0f}%" if total_keys > 0 else "N/A",
|
||||
"max_depth": max_depth,
|
||||
"findings": findings,
|
||||
"finding_counts": counts,
|
||||
}
|
||||
|
||||
if output_format == "json":
|
||||
print(json.dumps(result, indent=2))
|
||||
return result
|
||||
|
||||
# Text output
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Values.yaml Validation Report")
|
||||
print(f"{'=' * 60}")
|
||||
print(f" Score: {score}/100")
|
||||
print(f" Keys: {total_keys} | Documented: {documented} ({result['documentation_coverage']})")
|
||||
print(f" Max Depth: {max_depth}")
|
||||
print()
|
||||
print(f" Findings: {counts['critical']} critical | {counts['high']} high | {counts['medium']} medium | {counts['low']} low")
|
||||
print(f"{'─' * 60}")
|
||||
|
||||
for f in findings:
|
||||
icon = {"critical": "!!!", "high": "!!", "medium": "!", "low": "~"}.get(f["severity"], "?")
|
||||
print(f"\n {icon} {f['severity'].upper()} [{f['category']}]")
|
||||
print(f" {f['message']}")
|
||||
if f.get("line", 0) > 0:
|
||||
print(f" Line: {f['line']}")
|
||||
print(f" Fix: {f['fix']}")
|
||||
|
||||
if not findings:
|
||||
print("\n No issues found. Values file looks good.")
|
||||
|
||||
print(f"\n{'=' * 60}\n")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="helm-chart-builder: values.yaml best-practice validator"
|
||||
)
|
||||
parser.add_argument("valuesfile", nargs="?", help="Path to values.yaml (omit for demo)")
|
||||
parser.add_argument(
|
||||
"--output", "-o",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Strict mode — elevate warnings to higher severity",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.valuesfile:
|
||||
path = Path(args.valuesfile)
|
||||
if not path.exists():
|
||||
print(f"Error: File not found: {args.valuesfile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
content = path.read_text(encoding="utf-8")
|
||||
else:
|
||||
print("No values file provided. Running demo validation...\n")
|
||||
content = DEMO_VALUES
|
||||
|
||||
generate_report(content, args.output, args.strict)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user