# https://helm.sh/docs/topics/charts/#the-chartyaml-file
import os
import yaml
from projspec.proj.base import ParseFailed, ProjectSpec
from projspec.utils import AttrDict
[docs]
class HelmChart(ProjectSpec):
"""A Kubernetes application packaged as a Helm chart.
A Helm chart is a directory tree containing a `Chart.yaml` manifest,
a `templates/` directory of Kubernetes resource manifests, and an
optional `values.yaml` file with default configuration values.
Dependency charts may be declared in `Chart.yaml` under the
`dependencies` key; pinned versions are recorded in `Chart.lock`.
"""
icon = "☸️"
spec_doc = "https://helm.sh/docs/topics/charts/#the-chartyaml-file"
def match(self) -> bool:
return "Chart.yaml" in self.proj.basenames
def parse(self) -> None:
from projspec.artifact.base import FileArtifact
from projspec.artifact.deployment import HelmDeployment
from projspec.artifact.process import Process
from projspec.content.metadata import DescriptiveMetadata
try:
with self.proj.fs.open(self.proj.basenames["Chart.yaml"], "rt") as f:
chart = yaml.safe_load(f)
except (OSError, yaml.YAMLError) as exc:
raise ParseFailed(f"Could not read Chart.yaml: {exc}") from exc
if not isinstance(chart, dict):
raise ParseFailed("Chart.yaml did not parse to a mapping")
name = chart.get("name", "")
version = chart.get("version", "")
meta: dict[str, str] = {}
for key in (
"name",
"version",
"appVersion",
"description",
"type",
"home",
"icon",
):
val = chart.get(key)
if val is not None:
meta[key] = str(val)
keywords = chart.get("keywords", [])
if keywords:
meta["keywords"] = ", ".join(keywords)
maintainers = chart.get("maintainers", [])
if maintainers:
# Each entry: {name, email, url} — flatten to a readable string
meta["maintainers"] = ", ".join(
m.get("name", "") for m in maintainers if isinstance(m, dict)
)
self._contents = AttrDict(
descriptive_metadata=DescriptiveMetadata(proj=self.proj, meta=meta)
)
arts = AttrDict()
# helm package . → produces <name>-<version>.tgz
if name and version:
arts["packaged_chart"] = FileArtifact(
proj=self.proj,
cmd=["helm", "package", "."],
fn=f"{self.proj.url}/{name}-{version}.tgz",
)
# helm dependency update → populates charts/ and writes Chart.lock
arts["chart_lock"] = FileArtifact(
proj=self.proj,
cmd=["helm", "dependency", "update", "."],
fn=f"{self.proj.url}/Chart.lock",
)
# helm install / upgrade → deploys to the active k8s cluster
release = name or "release"
arts["release"] = HelmDeployment(
proj=self.proj,
release=release,
)
# helm lint — validates chart structure and values
arts["lint"] = Process(
proj=self.proj,
cmd=["helm", "lint", "."],
)
self._artifacts = arts
@staticmethod
def _create(path: str) -> None:
"""Scaffold a minimal but valid Helm chart directory."""
name = os.path.basename(path)
# Chart.yaml — required manifest
with open(f"{path}/Chart.yaml", "wt") as f:
f.write(
f"apiVersion: v2\n"
f"name: {name}\n"
f"description: A Helm chart for {name}\n"
f"type: application\n"
f"version: 0.1.0\n"
f'appVersion: "1.0.0"\n'
)
# values.yaml — default configuration values
with open(f"{path}/values.yaml", "wt") as f:
f.write(
"replicaCount: 1\n"
"\n"
"image:\n"
f" repository: {name}\n"
" tag: latest\n"
" pullPolicy: IfNotPresent\n"
"\n"
"service:\n"
" type: ClusterIP\n"
" port: 80\n"
)
# templates/ directory with a minimal Deployment manifest
os.makedirs(f"{path}/templates", exist_ok=True)
with open(f"{path}/templates/deployment.yaml", "wt") as f:
f.write(
"apiVersion: apps/v1\n"
"kind: Deployment\n"
"metadata:\n"
f" name: {name}\n"
"spec:\n"
" replicas: {{ .Values.replicaCount }}\n"
" selector:\n"
" matchLabels:\n"
f" app: {name}\n"
" template:\n"
" metadata:\n"
" labels:\n"
f" app: {name}\n"
" spec:\n"
" containers:\n"
f" - name: {name}\n"
' image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"\n'
" imagePullPolicy: {{ .Values.image.pullPolicy }}\n"
" ports:\n"
" - containerPort: {{ .Values.service.port }}\n"
)