Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/projspec/artifact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from projspec.artifact.base import BaseArtifact, FileArtifact
from projspec.artifact.container import DockerImage
from projspec.artifact.deployment import Deployment, HelmDeployment
from projspec.artifact.installable import CondaPackage, Wheel
from projspec.artifact.linter import PreCommit
from projspec.artifact.process import Process
Expand All @@ -11,6 +12,8 @@
"BaseArtifact",
"FileArtifact",
"DockerImage",
"Deployment",
"HelmDeployment",
"CondaPackage",
"Wheel",
"Process",
Expand Down
87 changes: 87 additions & 0 deletions src/projspec/artifact/deployment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from projspec.artifact import BaseArtifact
from projspec.proj.base import Project
from projspec.utils import run_subprocess


class Deployment(BaseArtifact):
"""A named release deployed to an external orchestrator (e.g. a Kubernetes cluster).

Unlike a local :class:`~projspec.artifact.process.Process`, a ``Deployment``
has no local subprocess handle. "Done" is inferred by querying the
orchestrator; "clean" means the release has been uninstalled.

Subclasses should override :meth:`_is_done`, :meth:`_is_clean`, and
:meth:`clean` for their specific orchestrator. The default implementations
here are suitable for a Helm release:

* :meth:`make` runs ``helm upgrade --install <release> .``
* :meth:`clean` runs ``helm uninstall <release>``
* :meth:`_is_done` / :meth:`_is_clean` query ``helm status <release>``
"""

def __init__(
self,
proj: Project,
cmd: list[str] | None = None,
release: str = "",
clean_cmd: list[str] | None = None,
**kwargs,
):
self.release = release
self.clean_cmd = clean_cmd
super().__init__(proj, cmd=cmd, **kwargs)

def _make(self, **kwargs):
run_subprocess(self.cmd, cwd=self.proj.url, output=False, **kwargs)

def clean(self):
"""Tear down the deployment (e.g. ``helm uninstall <release>``)."""
if self.clean_cmd:
run_subprocess(self.clean_cmd, cwd=self.proj.url, output=False)

def _is_done(self) -> bool:
"""Return True when the release exists and is deployed."""
return False # conservative default; subclasses or callers may override

def _is_clean(self) -> bool:
"""Return True when no release is present."""
return True # conservative default


class HelmDeployment(Deployment):
"""A Helm release deployed to the active Kubernetes cluster.

:param release: the Helm release name passed to ``helm upgrade --install``.

``make()`` runs::

helm upgrade --install <release> .

``clean()`` runs::

helm uninstall <release>

``state`` is resolved by running ``helm status <release>``:

* ``"done"`` — release exists and is deployed (exit code 0)
* ``"clean"`` — release does not exist (exit code non-zero / not found)
"""

def __init__(self, proj: Project, release: str, **kwargs):
cmd = ["helm", "upgrade", "--install", release, "."]
clean_cmd = ["helm", "uninstall", release]
super().__init__(proj, cmd=cmd, release=release, clean_cmd=clean_cmd, **kwargs)

def _is_done(self) -> bool:
try:
run_subprocess(
["helm", "status", self.release],
cwd=self.proj.url,
output=False,
)
return True
except Exception:
return False

def _is_clean(self) -> bool:
return not self._is_done()
2 changes: 2 additions & 0 deletions src/projspec/proj/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from projspec.proj.documentation import RTD, MDBook
from projspec.proj.git import GitRepo
from projspec.proj.golang import Golang
from projspec.proj.helm import HelmChart
from projspec.proj.hf import HuggingFaceRepo
from projspec.proj.ide import JetbrainsIDE, NvidiaAIWorkbench, VSCode
from projspec.proj.node import JLabExtension, Node, Yarn
Expand All @@ -34,6 +35,7 @@
"CondaProject",
"Golang",
"GitRepo",
"HelmChart",
"HuggingFaceRepo",
"JetbrainsIDE",
"JLabExtension",
Expand Down
167 changes: 167 additions & 0 deletions src/projspec/proj/helm.py
Original file line number Diff line number Diff line change
@@ -1 +1,168 @@
# 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


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``.
"""

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

# ------------------------------------------------------------------ #
# Chart.yaml — required by the Helm spec
# ------------------------------------------------------------------ #
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", "")

# ------------------------------------------------------------------ #
# Contents
# ------------------------------------------------------------------ #
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)
)

# ------------------------------------------------------------------ #
# Artifacts
# ------------------------------------------------------------------ #
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"
)
1 change: 1 addition & 0 deletions tests/test_roundtrips.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"uv",
"briefcase",
"conda_project",
"helm_chart",
],
)
def test_compliant(tmpdir, cls_name):
Expand Down
Loading