diff --git a/engineering/helm-chart-builder/.claude-plugin/plugin.json b/engineering/helm-chart-builder/.claude-plugin/plugin.json new file mode 100644 index 0000000..61f270d --- /dev/null +++ b/engineering/helm-chart-builder/.claude-plugin/plugin.json @@ -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": "./" +} diff --git a/engineering/helm-chart-builder/SKILL.md b/engineering/helm-chart-builder/SKILL.md new file mode 100644 index 0000000..ab90e82 --- /dev/null +++ b/engineering/helm-chart-builder/SKILL.md @@ -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. diff --git a/engineering/helm-chart-builder/references/chart-patterns.md b/engineering/helm-chart-builder/references/chart-patterns.md new file mode 100644 index 0000000..ebb06dc --- /dev/null +++ b/engineering/helm-chart-builder/references/chart-patterns.md @@ -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 | diff --git a/engineering/helm-chart-builder/references/values-design.md b/engineering/helm-chart-builder/references/values-design.md new file mode 100644 index 0000000..47cec87 --- /dev/null +++ b/engineering/helm-chart-builder/references/values-design.md @@ -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? | diff --git a/engineering/helm-chart-builder/scripts/chart_analyzer.py b/engineering/helm-chart-builder/scripts/chart_analyzer.py new file mode 100644 index 0000000..2891f9b --- /dev/null +++ b/engineering/helm-chart-builder/scripts/chart_analyzer.py @@ -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() diff --git a/engineering/helm-chart-builder/scripts/values_validator.py b/engineering/helm-chart-builder/scripts/values_validator.py new file mode 100644 index 0000000..182031c --- /dev/null +++ b/engineering/helm-chart-builder/scripts/values_validator.py @@ -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()