Skip to content

Commit 47f4e05

Browse files
liadsh-twineclaude
andcommitted
[helm] Support a name-keyed map for dagster-user-deployments.deployments
Allow `dagster-user-deployments.deployments` to be specified as a map keyed by deployment name, in addition to the existing array form. Helm merges maps by key but replaces arrays wholesale, so the map form lets layered values files (e.g. a base file plus per-environment overrides, as used with an ArgoCD ApplicationSet) override a single field of a single deployment instead of restating the entire list. A new `dagsterUserDeployments.deploymentsList` template helper normalizes the value (list or map) into a canonical list; when a map is used the key becomes the deployment name. Every existing `range` over deployments (three subchart templates and the parent chart's workspace configmap, daemon init containers, and webserver init containers) is routed through the helper, so array-form output is unchanged. The pydantic models are updated so `deployments` becomes `list[UserDeployment] | dict[str, UserDeployment]`. `name` is optional on the base model (the map key supplies it), but it stays required for the list form: a `_RequireName` annotation overlays `required: ["name"]` onto the array branch of the generated JSON schema via `allOf`, reusing the single `UserDeployment` $def, so a nameless list entry still fails schema validation / `helm lint`. Both values.schema.json files are regenerated via `dagster-helm schema apply`. Adds a commented map example to both values.yaml files and a docs section (noting map keys render in sorted order), plus pytest coverage for the map form (rendering, naming, array/map equivalence, key-wins-over-name, and the missing-name guard). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c0b7f1 commit 47f4e05

17 files changed

Lines changed: 369 additions & 28 deletions

File tree

docs/docs/deployment/oss/deployment-options/kubernetes/customizing-your-deployment.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,26 @@ global:
229229
generatePostgresqlPasswordSecret: false
230230
```
231231

232+
## Defining user code deployments as a map
233+
234+
The `dagster-user-deployments.deployments` value accepts either a list (the default) or a map keyed by deployment name:
235+
236+
```yaml
237+
dagster-user-deployments:
238+
deployments:
239+
k8s-example-user-code-1:
240+
image:
241+
repository: 'docker.io/dagster/user-code-example'
242+
tag: ~
243+
pullPolicy: Always
244+
dagsterApiGrpcArgs:
245+
- '--python-file'
246+
- '/example_project/example_repo/repo.py'
247+
port: 3030
248+
```
249+
250+
The map form is useful when you layer values files — for example a base file plus a per-environment override. Helm merges maps by key but replaces lists wholesale, so with the map form an override file can change a single field of a single deployment instead of restating the entire list. When the map form is used, the map key is the deployment name and the per-entry `name` field is optional. Note that Helm renders map keys in alphabetical order, so deployments are always emitted in sorted key order rather than declaration order.
251+
232252
## Security
233253

234254
Users will likely want to permission a ServiceAccount bound to a properly scoped Role to launch Jobs and create other Kubernetes resources.

helm/dagster/charts/dagster-user-deployments/templates/configmap-env-user.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{{ range $deployment := .Values.deployments }}
1+
{{ range $deployment := (include "dagsterUserDeployments.deploymentsList" .Values.deployments | fromYamlArray) }}
22
apiVersion: v1
33
kind: ConfigMap
44
metadata:

helm/dagster/charts/dagster-user-deployments/templates/deployment-user.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{{- $celeryConfigSecretName := .Values.global.celeryConfigSecretName | default .Values.celeryConfigSecretName }}
2-
{{ range $deployment := .Values.deployments }}
2+
{{ range $deployment := (include "dagsterUserDeployments.deploymentsList" .Values.deployments | fromYamlArray) }}
33
{{- $userDeploymentChecksum := $deployment | toJson | sha256sum }}
44
{{- if and $deployment.codeServerArgs (gt (int ($deployment.replicaCount | default 1)) 1) }}
55
{{- fail (printf "deployment %q: codeServerArgs cannot be used with replicaCount > 1. A reloadable code server cannot be reloaded consistently across multiple replicas behind a round-robin service." $deployment.name) }}

helm/dagster/charts/dagster-user-deployments/templates/helpers/_helpers.tpl

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ Expand the name of the chart.
66
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
77
{{- end -}}
88

9+
{{/*
10+
Normalize the user-code deployments value into a list.
11+
Accepts either a list of deployment entries or a map keyed by deployment name.
12+
For the map form, the key becomes each entry's `name` (the key wins over any `name` field).
13+
Emits YAML that call sites parse with `fromYamlArray`.
14+
15+
NOTE: this helper is intentionally duplicated verbatim in
16+
helm/dagster/templates/helpers/_helpers.tpl
17+
so it is available whether the subchart is rendered under the umbrella chart or
18+
standalone. Keep both copies in sync when making changes.
19+
*/}}
20+
{{- define "dagsterUserDeployments.deploymentsList" -}}
21+
{{- $deployments := . -}}
22+
{{- if kindIs "map" $deployments -}}
23+
{{- $list := list -}}
24+
{{- range $name, $deployment := $deployments -}}
25+
{{- $entry := merge (dict "name" $name) (deepCopy $deployment) -}}
26+
{{- $list = append $list $entry -}}
27+
{{- end -}}
28+
{{- $list | toYaml -}}
29+
{{- else -}}
30+
{{- range $deployment := (default (list) $deployments) -}}
31+
{{- if not $deployment.name }}{{ fail "each user deployment must set 'name' (or be provided as a name-keyed map)" }}{{- end }}
32+
{{- end -}}
33+
{{- (default (list) $deployments) | toYaml -}}
34+
{{- end -}}
35+
{{- end -}}
36+
937
{{/*
1038
Create a default fully qualified app name.
1139
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).

helm/dagster/charts/dagster-user-deployments/templates/service-user.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{{ range $deployment := .Values.deployments }}
1+
{{ range $deployment := (include "dagsterUserDeployments.deploymentsList" .Values.deployments | fromYamlArray) }}
22
apiVersion: v1
33
kind: Service
44
metadata:

helm/dagster/charts/dagster-user-deployments/values.schema.json

Lines changed: 34 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

helm/dagster/charts/dagster-user-deployments/values.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,25 @@ includeInstance: false
2828
# pipeline execution use the same image, we recommend using a unique tag (ie not "latest").
2929
#
3030
# All user code will be invoked within the images.
31+
#
32+
# "deployments" may be provided either as a list (shown below) or as a map keyed by
33+
# deployment name. The map form lets layered values files (e.g. a base file plus a
34+
# per-environment override) deep-merge by key instead of replacing the whole list, so
35+
# an override can change a single field of a single deployment. When the map form is
36+
# used, the map key is the deployment name and the per-entry "name" field is optional.
37+
#
38+
# Example map form:
39+
#
40+
# deployments:
41+
# k8s-example-user-code-1:
42+
# image:
43+
# repository: "docker.io/dagster/user-code-example"
44+
# tag: ~
45+
# pullPolicy: Always
46+
# dagsterApiGrpcArgs:
47+
# - "-f"
48+
# - "/example_project/example_repo/repo.py"
49+
# port: 3030
3150
####################################################################################################
3251
deployments:
3352
- name: "k8s-example-user-code-1"

helm/dagster/schema/schema/charts/dagster_user_deployments/subschema/user_deployments.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
1-
from pydantic import BaseModel, Field, create_model
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel, Field, GetJsonSchemaHandler, create_model
4+
from pydantic.json_schema import JsonSchemaValue
5+
from pydantic_core import CoreSchema
26

37
from schema.charts.utils import kubernetes
48

59

10+
class _RequireName:
11+
"""Annotation that overlays ``required: ["name"]`` onto a ``UserDeployment``'s JSON
12+
schema. Applied only to the list form of ``deployments`` so that a missing ``name`` in
13+
an array entry is still caught by schema validation (``helm lint``), while the map form
14+
— where the map key supplies the name — leaves ``name`` optional. Reuses the shared
15+
``UserDeployment`` ``$def`` via ``allOf`` rather than duplicating it.
16+
"""
17+
18+
def __get_pydantic_json_schema__(
19+
self, core_schema: CoreSchema, handler: GetJsonSchemaHandler
20+
) -> JsonSchemaValue:
21+
return {"allOf": [handler(core_schema), {"required": ["name"]}]}
22+
23+
624
class UserDeploymentIncludeConfigInLaunchedRuns(BaseModel):
725
enabled: bool
826

@@ -13,7 +31,9 @@ class UserDeploymentIncludeConfigInLaunchedRuns(BaseModel):
1331

1432

1533
class UserDeployment(BaseModel):
16-
name: str
34+
# Optional so that deployments can be supplied as a name-keyed map, where the
35+
# map key provides the name. For the list form, the Helm templates require a name.
36+
name: str | None = None
1737
image: kubernetes.Image
1838
dagsterApiGrpcArgs: list[str] | None = None
1939
codeServerArgs: list[str] | None = None
@@ -45,8 +65,13 @@ class UserDeployment(BaseModel):
4565
deploymentStrategy: kubernetes.DeploymentStrategy | None = None
4666

4767

68+
# `deployments` may be either a list of deployments (each requiring a `name`) or a map
69+
# keyed by deployment name (where the key supplies the name, so `name` is optional).
70+
UserDeploymentsValue = list[Annotated[UserDeployment, _RequireName()]] | dict[str, UserDeployment]
71+
72+
4873
class UserDeployments(BaseModel):
4974
enabled: bool
5075
enableSubchart: bool
5176
imagePullSecrets: list[kubernetes.SecretRef]
52-
deployments: list[UserDeployment]
77+
deployments: UserDeploymentsValue

helm/dagster/schema/schema/charts/dagster_user_deployments/values.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pydantic import BaseModel, Field
22

33
from schema.charts.dagster.subschema import Global, ServiceAccount
4-
from schema.charts.dagster_user_deployments.subschema.user_deployments import UserDeployment
4+
from schema.charts.dagster_user_deployments.subschema.user_deployments import UserDeploymentsValue
55
from schema.charts.utils import kubernetes
66

77

@@ -12,7 +12,7 @@ class DagsterUserDeploymentsHelmValues(BaseModel):
1212
postgresqlSecretName: str
1313
celeryConfigSecretName: str
1414
includeInstance: bool
15-
deployments: list[UserDeployment]
15+
deployments: UserDeploymentsValue
1616
imagePullSecrets: list[kubernetes.SecretRef]
1717
serviceAccount: ServiceAccount
1818
global_: Global = Field(..., alias="global")

helm/dagster/schema/schema_tests/test_user_deployments.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1959,3 +1959,123 @@ def test_include_instance(subchart_template: HelmTemplate, include_instance: boo
19591959
)
19601960
else:
19611961
assert "dagster-instance" not in volume_names
1962+
1963+
1964+
@pytest.fixture(name="service_template")
1965+
def service_helm_template() -> HelmTemplate:
1966+
return HelmTemplate(
1967+
helm_dir_path="helm/dagster",
1968+
subchart_paths=["charts/dagster-user-deployments"],
1969+
output="charts/dagster-user-deployments/templates/service-user.yaml",
1970+
model=models.V1Service,
1971+
)
1972+
1973+
1974+
def _map_helm_values(deployments: dict[str, UserDeployment]) -> DagsterHelmValues:
1975+
return DagsterHelmValues.construct(
1976+
dagsterUserDeployments=UserDeployments.construct(
1977+
enabled=True,
1978+
enableSubchart=True,
1979+
deployments=deployments,
1980+
)
1981+
)
1982+
1983+
1984+
def test_deployments_map_render(template: HelmTemplate):
1985+
# deployments provided as a name-keyed map; the key becomes each deployment's name.
1986+
deployments = {
1987+
"deployment-one": create_simple_user_deployment("deployment-one"),
1988+
"deployment-two": create_complex_user_deployment("deployment-two"),
1989+
}
1990+
user_deployments = template.render(_map_helm_values(deployments))
1991+
1992+
assert len(user_deployments) == len(deployments)
1993+
1994+
# Helm ranges maps in sorted-key order.
1995+
for user_deployment, name in zip(user_deployments, sorted(deployments)):
1996+
assert user_deployment.metadata.name.endswith(name)
1997+
assert user_deployment.metadata.labels["deployment"] == name
1998+
assert user_deployment.spec.template.metadata.labels["deployment"] == name
1999+
2000+
2001+
def test_deployments_map_matches_list(template: HelmTemplate):
2002+
# The same deployment expressed as a single-entry list vs a single-entry map should
2003+
# render an identical Deployment (including the config checksum annotation).
2004+
list_values = DagsterHelmValues.construct(
2005+
dagsterUserDeployments=UserDeployments.construct(
2006+
enabled=True,
2007+
enableSubchart=True,
2008+
deployments=[create_complex_user_deployment("deployment-one")],
2009+
)
2010+
)
2011+
map_values = _map_helm_values(
2012+
{"deployment-one": create_complex_user_deployment("deployment-one")}
2013+
)
2014+
2015+
[from_list] = template.render(list_values)
2016+
[from_map] = template.render(map_values)
2017+
2018+
assert template.api_client.sanitize_for_serialization(
2019+
from_list
2020+
) == template.api_client.sanitize_for_serialization(from_map)
2021+
2022+
2023+
def test_deployments_map_key_wins_over_name(template: HelmTemplate):
2024+
# When both a map key and an entry-level `name` are present, the key is authoritative.
2025+
deployment = create_simple_user_deployment("ignored-name")
2026+
user_deployments = template.render(_map_helm_values({"key-name": deployment}))
2027+
2028+
[user_deployment] = user_deployments
2029+
assert user_deployment.metadata.name.endswith("key-name")
2030+
assert user_deployment.metadata.labels["deployment"] == "key-name"
2031+
2032+
2033+
def test_deployments_map_service(service_template: HelmTemplate):
2034+
deployments = {
2035+
"deployment-one": create_simple_user_deployment("deployment-one"),
2036+
"deployment-two": create_simple_user_deployment("deployment-two"),
2037+
}
2038+
services = service_template.render(_map_helm_values(deployments))
2039+
2040+
assert len(services) == len(deployments)
2041+
for service, name in zip(services, sorted(deployments)):
2042+
assert service.metadata.name == name
2043+
assert service.spec.selector["deployment"] == name
2044+
2045+
2046+
def test_deployments_map_configmap_env(user_deployment_configmap_template: HelmTemplate):
2047+
deployment = UserDeployment(
2048+
image=kubernetes.Image(repository="repo/a", tag="tag1", pullPolicy="Always"),
2049+
dagsterApiGrpcArgs=["-m", "a"],
2050+
port=3030,
2051+
env={"FOO": "bar"},
2052+
)
2053+
configmaps = user_deployment_configmap_template.render(_map_helm_values({"code-a": deployment}))
2054+
2055+
[configmap] = configmaps
2056+
assert configmap.metadata.name.endswith("-code-a-user-env")
2057+
assert configmap.data["FOO"] == "bar"
2058+
2059+
2060+
def test_deployments_list_missing_name_fails(template: HelmTemplate, capfd):
2061+
# A list entry without a name is invalid (only the map form can omit it). The schema
2062+
# requires `name` for the array branch, so this fails at values-validation time; the
2063+
# template also guards it as a backstop when schema validation is skipped.
2064+
nameless = UserDeployment.construct(
2065+
image=kubernetes.Image(repository="repo/x", tag="tag1", pullPolicy="Always"),
2066+
dagsterApiGrpcArgs=["-m", "x"],
2067+
port=3030,
2068+
)
2069+
with pytest.raises(subprocess.CalledProcessError):
2070+
template.render(
2071+
DagsterHelmValues.construct(
2072+
dagsterUserDeployments=UserDeployments.construct(
2073+
enabled=True,
2074+
enableSubchart=True,
2075+
deployments=[nameless],
2076+
)
2077+
)
2078+
)
2079+
2080+
_, err = capfd.readouterr()
2081+
assert "name is required" in err

0 commit comments

Comments
 (0)