Skip to content

Commit 25792f6

Browse files
authored
Merge pull request #78 from martindurant/helm-chart
AI Code: add implementation for helm charts
2 parents 4c43138 + 49feb3a commit 25792f6

File tree

5 files changed

+260
-0
lines changed

5 files changed

+260
-0
lines changed

src/projspec/artifact/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from projspec.artifact.base import BaseArtifact, FileArtifact
44
from projspec.artifact.container import DockerImage
5+
from projspec.artifact.deployment import Deployment, HelmDeployment
56
from projspec.artifact.installable import CondaPackage, Wheel
67
from projspec.artifact.linter import PreCommit
78
from projspec.artifact.process import Process
@@ -11,6 +12,8 @@
1112
"BaseArtifact",
1213
"FileArtifact",
1314
"DockerImage",
15+
"Deployment",
16+
"HelmDeployment",
1417
"CondaPackage",
1518
"Wheel",
1619
"Process",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from projspec.artifact import BaseArtifact
2+
from projspec.proj.base import Project
3+
from projspec.utils import run_subprocess
4+
5+
6+
class Deployment(BaseArtifact):
7+
"""A named release deployed to an external orchestrator (e.g. a Kubernetes cluster).
8+
9+
Unlike a local :class:`~projspec.artifact.process.Process`, a ``Deployment``
10+
has no local subprocess handle. "Done" is inferred by querying the
11+
orchestrator; "clean" means the release has been uninstalled.
12+
13+
Subclasses should override :meth:`_is_done`, :meth:`_is_clean`, and
14+
:meth:`clean` for their specific orchestrator. The default implementations
15+
here are suitable for a Helm release:
16+
17+
* :meth:`make` runs ``helm upgrade --install <release> .``
18+
* :meth:`clean` runs ``helm uninstall <release>``
19+
* :meth:`_is_done` / :meth:`_is_clean` query ``helm status <release>``
20+
"""
21+
22+
def __init__(
23+
self,
24+
proj: Project,
25+
cmd: list[str] | None = None,
26+
release: str = "",
27+
clean_cmd: list[str] | None = None,
28+
**kwargs,
29+
):
30+
self.release = release
31+
self.clean_cmd = clean_cmd
32+
super().__init__(proj, cmd=cmd, **kwargs)
33+
34+
def _make(self, **kwargs):
35+
run_subprocess(self.cmd, cwd=self.proj.url, output=False, **kwargs)
36+
37+
def clean(self):
38+
"""Tear down the deployment (e.g. ``helm uninstall <release>``)."""
39+
if self.clean_cmd:
40+
run_subprocess(self.clean_cmd, cwd=self.proj.url, output=False)
41+
42+
def _is_done(self) -> bool:
43+
"""Return True when the release exists and is deployed."""
44+
return False # conservative default; subclasses or callers may override
45+
46+
def _is_clean(self) -> bool:
47+
"""Return True when no release is present."""
48+
return True # conservative default
49+
50+
51+
class HelmDeployment(Deployment):
52+
"""A Helm release deployed to the active Kubernetes cluster.
53+
54+
:param release: the Helm release name passed to ``helm upgrade --install``.
55+
56+
``make()`` runs::
57+
58+
helm upgrade --install <release> .
59+
60+
``clean()`` runs::
61+
62+
helm uninstall <release>
63+
64+
``state`` is resolved by running ``helm status <release>``:
65+
66+
* ``"done"`` — release exists and is deployed (exit code 0)
67+
* ``"clean"`` — release does not exist (exit code non-zero / not found)
68+
"""
69+
70+
def __init__(self, proj: Project, release: str, **kwargs):
71+
cmd = ["helm", "upgrade", "--install", release, "."]
72+
clean_cmd = ["helm", "uninstall", release]
73+
super().__init__(proj, cmd=cmd, release=release, clean_cmd=clean_cmd, **kwargs)
74+
75+
def _is_done(self) -> bool:
76+
try:
77+
run_subprocess(
78+
["helm", "status", self.release],
79+
cwd=self.proj.url,
80+
output=False,
81+
)
82+
return True
83+
except Exception:
84+
return False
85+
86+
def _is_clean(self) -> bool:
87+
return not self._is_done()

src/projspec/proj/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from projspec.proj.documentation import RTD, MDBook
1111
from projspec.proj.git import GitRepo
1212
from projspec.proj.golang import Golang
13+
from projspec.proj.helm import HelmChart
1314
from projspec.proj.hf import HuggingFaceRepo
1415
from projspec.proj.ide import JetbrainsIDE, NvidiaAIWorkbench, VSCode
1516
from projspec.proj.node import JLabExtension, Node, Yarn
@@ -34,6 +35,7 @@
3435
"CondaProject",
3536
"Golang",
3637
"GitRepo",
38+
"HelmChart",
3739
"HuggingFaceRepo",
3840
"JetbrainsIDE",
3941
"JLabExtension",

src/projspec/proj/helm.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,168 @@
11
# https://helm.sh/docs/topics/charts/#the-chartyaml-file
2+
import os
3+
4+
import yaml
5+
6+
from projspec.proj.base import ParseFailed, ProjectSpec
7+
from projspec.utils import AttrDict
8+
9+
10+
class HelmChart(ProjectSpec):
11+
"""A Kubernetes application packaged as a Helm chart.
12+
13+
A Helm chart is a directory tree containing a ``Chart.yaml`` manifest,
14+
a ``templates/`` directory of Kubernetes resource manifests, and an
15+
optional ``values.yaml`` file with default configuration values.
16+
Dependency charts may be declared in ``Chart.yaml`` under the
17+
``dependencies`` key; pinned versions are recorded in ``Chart.lock``.
18+
"""
19+
20+
spec_doc = "https://helm.sh/docs/topics/charts/#the-chartyaml-file"
21+
22+
def match(self) -> bool:
23+
return "Chart.yaml" in self.proj.basenames
24+
25+
def parse(self) -> None:
26+
from projspec.artifact.base import FileArtifact
27+
from projspec.artifact.deployment import HelmDeployment
28+
from projspec.artifact.process import Process
29+
from projspec.content.metadata import DescriptiveMetadata
30+
31+
# ------------------------------------------------------------------ #
32+
# Chart.yaml — required by the Helm spec
33+
# ------------------------------------------------------------------ #
34+
try:
35+
with self.proj.fs.open(self.proj.basenames["Chart.yaml"], "rt") as f:
36+
chart = yaml.safe_load(f)
37+
except (OSError, yaml.YAMLError) as exc:
38+
raise ParseFailed(f"Could not read Chart.yaml: {exc}") from exc
39+
40+
if not isinstance(chart, dict):
41+
raise ParseFailed("Chart.yaml did not parse to a mapping")
42+
43+
name = chart.get("name", "")
44+
version = chart.get("version", "")
45+
46+
# ------------------------------------------------------------------ #
47+
# Contents
48+
# ------------------------------------------------------------------ #
49+
meta: dict[str, str] = {}
50+
for key in (
51+
"name",
52+
"version",
53+
"appVersion",
54+
"description",
55+
"type",
56+
"home",
57+
"icon",
58+
):
59+
val = chart.get(key)
60+
if val is not None:
61+
meta[key] = str(val)
62+
63+
keywords = chart.get("keywords", [])
64+
if keywords:
65+
meta["keywords"] = ", ".join(keywords)
66+
67+
maintainers = chart.get("maintainers", [])
68+
if maintainers:
69+
# Each entry: {name, email, url} — flatten to a readable string
70+
meta["maintainers"] = ", ".join(
71+
m.get("name", "") for m in maintainers if isinstance(m, dict)
72+
)
73+
74+
self._contents = AttrDict(
75+
descriptive_metadata=DescriptiveMetadata(proj=self.proj, meta=meta)
76+
)
77+
78+
# ------------------------------------------------------------------ #
79+
# Artifacts
80+
# ------------------------------------------------------------------ #
81+
arts = AttrDict()
82+
83+
# helm package . → produces <name>-<version>.tgz
84+
if name and version:
85+
arts["packaged_chart"] = FileArtifact(
86+
proj=self.proj,
87+
cmd=["helm", "package", "."],
88+
fn=f"{self.proj.url}/{name}-{version}.tgz",
89+
)
90+
91+
# helm dependency update → populates charts/ and writes Chart.lock
92+
arts["chart_lock"] = FileArtifact(
93+
proj=self.proj,
94+
cmd=["helm", "dependency", "update", "."],
95+
fn=f"{self.proj.url}/Chart.lock",
96+
)
97+
98+
# helm install / upgrade → deploys to the active k8s cluster
99+
release = name or "release"
100+
arts["release"] = HelmDeployment(
101+
proj=self.proj,
102+
release=release,
103+
)
104+
105+
# helm lint — validates chart structure and values
106+
arts["lint"] = Process(
107+
proj=self.proj,
108+
cmd=["helm", "lint", "."],
109+
)
110+
111+
self._artifacts = arts
112+
113+
@staticmethod
114+
def _create(path: str) -> None:
115+
"""Scaffold a minimal but valid Helm chart directory."""
116+
name = os.path.basename(path)
117+
118+
# Chart.yaml — required manifest
119+
with open(f"{path}/Chart.yaml", "wt") as f:
120+
f.write(
121+
f"apiVersion: v2\n"
122+
f"name: {name}\n"
123+
f"description: A Helm chart for {name}\n"
124+
f"type: application\n"
125+
f"version: 0.1.0\n"
126+
f'appVersion: "1.0.0"\n'
127+
)
128+
129+
# values.yaml — default configuration values
130+
with open(f"{path}/values.yaml", "wt") as f:
131+
f.write(
132+
"replicaCount: 1\n"
133+
"\n"
134+
"image:\n"
135+
f" repository: {name}\n"
136+
" tag: latest\n"
137+
" pullPolicy: IfNotPresent\n"
138+
"\n"
139+
"service:\n"
140+
" type: ClusterIP\n"
141+
" port: 80\n"
142+
)
143+
144+
# templates/ directory with a minimal Deployment manifest
145+
os.makedirs(f"{path}/templates", exist_ok=True)
146+
with open(f"{path}/templates/deployment.yaml", "wt") as f:
147+
f.write(
148+
"apiVersion: apps/v1\n"
149+
"kind: Deployment\n"
150+
"metadata:\n"
151+
f" name: {name}\n"
152+
"spec:\n"
153+
" replicas: {{ .Values.replicaCount }}\n"
154+
" selector:\n"
155+
" matchLabels:\n"
156+
f" app: {name}\n"
157+
" template:\n"
158+
" metadata:\n"
159+
" labels:\n"
160+
f" app: {name}\n"
161+
" spec:\n"
162+
" containers:\n"
163+
f" - name: {name}\n"
164+
' image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"\n'
165+
" imagePullPolicy: {{ .Values.image.pullPolicy }}\n"
166+
" ports:\n"
167+
" - containerPort: {{ .Values.service.port }}\n"
168+
)

tests/test_roundtrips.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"uv",
3232
"briefcase",
3333
"conda_project",
34+
"helm_chart",
3435
],
3536
)
3637
def test_compliant(tmpdir, cls_name):

0 commit comments

Comments
 (0)