Skip to content

Commit 09fed87

Browse files
feat(helm): dedicated worker Deployment for process-role split (#197)
* feat(helm): dedicated worker Deployment for process-role split Close the P1-1 chart follow-up: worker.enabled (default false) renders a delivery-loop Deployment with AGENTFLOW_PROCESS_ROLE=worker, pins the API Deployment to role=api, and narrows the Service selector to component=api so workers do not take request traffic. Requires controlPlane.store=postgres; worker data volume is always emptyDir (no RWO multi-attach on the API PVC). Shared container env extracted to templates/_env.tpl. Unit contract: 23/23. * style: ruff format helm worker contract tests * fix(helm): newline before extraEnv in shared container env --------- Co-authored-by: JuliaEdom <uedomskikh@gmail.com>
1 parent b9cb961 commit 09fed87

7 files changed

Lines changed: 355 additions & 90 deletions

File tree

‎helm/agentflow/templates/_env.tpl‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
{{/*
2+
Common container env for API and worker Deployments.
3+
Caller: include "agentflow.containerEnv" (dict "root" . "secretName" $secretName "processRole" "api")
4+
processRole may be "" (omit AGENTFLOW_PROCESS_ROLE — app default 'all').
5+
*/}}
6+
{{- define "agentflow.containerEnv" -}}
7+
{{- $root := .root -}}
8+
{{- $secretName := .secretName -}}
9+
{{- $processRole := .processRole | default "" -}}
10+
- name: SERVING_BACKEND
11+
value: {{ $root.Values.serving.backend | quote }}
12+
{{- if eq $root.Values.serving.backend "clickhouse" }}
13+
- name: CLICKHOUSE_HOST
14+
value: {{ required "serving.clickhouse.host is required when serving.backend=clickhouse" $root.Values.serving.clickhouse.host | quote }}
15+
- name: CLICKHOUSE_PORT
16+
value: {{ $root.Values.serving.clickhouse.port | quote }}
17+
- name: CLICKHOUSE_DATABASE
18+
value: {{ $root.Values.serving.clickhouse.database | quote }}
19+
- name: CLICKHOUSE_USER
20+
value: {{ $root.Values.serving.clickhouse.user | quote }}
21+
{{- if $root.Values.serving.clickhouse.existingSecret }}
22+
- name: CLICKHOUSE_PASSWORD
23+
valueFrom:
24+
secretKeyRef:
25+
name: {{ $root.Values.serving.clickhouse.existingSecret | quote }}
26+
key: {{ $root.Values.serving.clickhouse.passwordKey | quote }}
27+
{{- end }}
28+
- name: CLICKHOUSE_SECURE
29+
value: {{ $root.Values.serving.clickhouse.secure | quote }}
30+
{{- if $root.Values.serving.clickhouse.tls.caSecret }}
31+
- name: CLICKHOUSE_CA_CERT
32+
value: /etc/agentflow/tls/clickhouse/{{ $root.Values.serving.clickhouse.tls.caKey }}
33+
{{- end }}
34+
{{- end }}
35+
- name: AGENTFLOW_CONTROLPLANE_STORE
36+
value: {{ $root.Values.controlPlane.store | quote }}
37+
{{- if eq $root.Values.controlPlane.store "postgres" }}
38+
- name: AGENTFLOW_CONTROLPLANE_PG_DSN
39+
valueFrom:
40+
secretKeyRef:
41+
name: {{ required "controlPlane.postgres.existingSecret is required when controlPlane.store=postgres (it must hold the PostgreSQL DSN; the chart ships no PG service)" $root.Values.controlPlane.postgres.existingSecret | quote }}
42+
key: {{ $root.Values.controlPlane.postgres.dsnKey | quote }}
43+
{{- end }}
44+
{{- if $processRole }}
45+
- name: AGENTFLOW_PROCESS_ROLE
46+
value: {{ $processRole | quote }}
47+
{{- end }}
48+
- name: DUCKDB_PATH
49+
value: {{ $root.Values.config.duckdbPath | quote }}
50+
- name: AGENTFLOW_USAGE_DB_PATH
51+
value: {{ $root.Values.config.usageDbPath | quote }}
52+
- name: AGENTFLOW_API_KEYS_FILE
53+
value: {{ $root.Values.config.apiKeysPath | quote }}
54+
- name: AGENTFLOW_TENANTS_FILE
55+
value: {{ $root.Values.config.tenantsPath | quote }}
56+
- name: AGENTFLOW_SLO_FILE
57+
value: {{ $root.Values.config.sloPath | quote }}
58+
- name: AGENTFLOW_SECURITY_CONFIG_FILE
59+
value: {{ $root.Values.config.securityPath | quote }}
60+
- name: AGENTFLOW_API_VERSIONS_FILE
61+
value: {{ $root.Values.config.apiVersionsPath | quote }}
62+
- name: AGENTFLOW_CONTRACTS_DIR
63+
value: {{ $root.Values.config.contractsDir | quote }}
64+
- name: AGENTFLOW_RATE_LIMIT_RPM
65+
value: {{ $root.Values.config.rateLimitRpm | quote }}
66+
- name: CACHE_TTL_SECONDS
67+
value: {{ $root.Values.config.cacheTtlSeconds | quote }}
68+
{{- if $root.Values.config.profile }}
69+
- name: AGENTFLOW_PROFILE
70+
value: {{ $root.Values.config.profile | quote }}
71+
{{- end }}
72+
- name: AGENTFLOW_CORS_ORIGINS
73+
value: {{ $root.Values.config.corsOrigins | quote }}
74+
- name: AGENTFLOW_ADMIN_KEY
75+
valueFrom:
76+
secretKeyRef:
77+
name: {{ $secretName }}
78+
key: admin-key
79+
{{- if $root.Values.config.redisUrl }}
80+
- name: REDIS_URL
81+
value: {{ $root.Values.config.redisUrl | quote }}
82+
{{- end }}
83+
{{- if $root.Values.config.otlpEndpoint }}
84+
- name: OTEL_EXPORTER_OTLP_ENDPOINT
85+
value: {{ $root.Values.config.otlpEndpoint | quote }}
86+
- name: OTEL_SERVICE_NAME
87+
value: {{ $root.Values.config.otlpServiceName | quote }}
88+
{{- end }}
89+
{{- with $root.Values.extraEnv }}
90+
{{/* Leading newline required: {{- toYaml }} would glue onto the previous value. */}}
91+
{{ toYaml . }}
92+
{{- end }}
93+
{{- end -}}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
{{- if .Values.worker.enabled }}
2+
{{- if ne .Values.controlPlane.store "postgres" }}
3+
{{- fail "worker.enabled requires controlPlane.store=postgres (AGENTFLOW_PROCESS_ROLE=api/worker needs a shared control plane; the embedded profile is single-process)." }}
4+
{{- end }}
5+
{{- $secretName := .Values.secrets.existingSecret | default (include "agentflow.fullname" .) }}
6+
apiVersion: apps/v1
7+
kind: Deployment
8+
metadata:
9+
name: {{ include "agentflow.fullname" . }}-worker
10+
labels:
11+
{{- include "agentflow.labels" . | nindent 4 }}
12+
app.kubernetes.io/component: worker
13+
spec:
14+
# Delivery loops claim rows in PostgreSQL; keep replicaCount small (default 1).
15+
# Scaling workers multiplies claim concurrency, not request capacity.
16+
replicas: {{ .Values.worker.replicaCount }}
17+
strategy:
18+
type: RollingUpdate
19+
rollingUpdate:
20+
maxUnavailable: 0
21+
maxSurge: 1
22+
selector:
23+
matchLabels:
24+
{{- include "agentflow.selectorLabels" . | nindent 6 }}
25+
app.kubernetes.io/component: worker
26+
template:
27+
metadata:
28+
labels:
29+
{{- include "agentflow.selectorLabels" . | nindent 8 }}
30+
app.kubernetes.io/component: worker
31+
{{- with .Values.podLabels }}
32+
{{- toYaml . | nindent 8 }}
33+
{{- end }}
34+
annotations:
35+
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
36+
{{- if .Values.secrets.create }}
37+
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
38+
{{- end }}
39+
{{- with .Values.podAnnotations }}
40+
{{- toYaml . | nindent 8 }}
41+
{{- end }}
42+
spec:
43+
serviceAccountName: {{ include "agentflow.serviceAccountName" . }}
44+
{{- with .Values.imagePullSecrets }}
45+
imagePullSecrets:
46+
{{- toYaml . | nindent 8 }}
47+
{{- end }}
48+
{{- with .Values.podSecurityContext }}
49+
securityContext:
50+
{{- toYaml . | nindent 8 }}
51+
{{- end }}
52+
containers:
53+
- name: agentflow-worker
54+
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
55+
imagePullPolicy: {{ .Values.image.pullPolicy }}
56+
{{- with .Values.containerSecurityContext }}
57+
securityContext:
58+
{{- toYaml . | nindent 12 }}
59+
{{- end }}
60+
command:
61+
- uvicorn
62+
- src.serving.api.main:app
63+
- --host
64+
- 0.0.0.0
65+
- --port
66+
- "8000"
67+
ports:
68+
- name: http
69+
containerPort: 8000
70+
protocol: TCP
71+
env:
72+
{{- include "agentflow.containerEnv" (dict "root" . "secretName" $secretName "processRole" "worker") | nindent 12 }}
73+
# Worker does not take Service traffic; probes stay dependency-light
74+
# so a serving-store blip does not thrash delivery.
75+
readinessProbe:
76+
httpGet:
77+
path: /health/live
78+
port: http
79+
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
80+
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
81+
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
82+
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
83+
livenessProbe:
84+
httpGet:
85+
path: /health/live
86+
port: http
87+
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
88+
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
89+
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
90+
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
91+
resources:
92+
{{- toYaml (.Values.worker.resources | default .Values.resources) | nindent 12 }}
93+
volumeMounts:
94+
- name: data
95+
mountPath: {{ .Values.persistence.mountPath | quote }}
96+
- name: config
97+
mountPath: /etc/agentflow/config
98+
readOnly: true
99+
- name: secrets
100+
mountPath: /etc/agentflow/secret
101+
readOnly: true
102+
{{- if and (eq .Values.serving.backend "clickhouse") .Values.serving.clickhouse.tls.caSecret }}
103+
- name: clickhouse-ca
104+
mountPath: /etc/agentflow/tls/clickhouse
105+
readOnly: true
106+
{{- end }}
107+
{{- if .Values.tmpVolume.enabled }}
108+
- name: tmp
109+
mountPath: /tmp
110+
{{- end }}
111+
volumes:
112+
- name: config
113+
configMap:
114+
name: {{ include "agentflow.fullname" . }}
115+
- name: secrets
116+
secret:
117+
secretName: {{ $secretName }}
118+
{{- if and (eq .Values.serving.backend "clickhouse") .Values.serving.clickhouse.tls.caSecret }}
119+
- name: clickhouse-ca
120+
secret:
121+
secretName: {{ .Values.serving.clickhouse.tls.caSecret | quote }}
122+
{{- end }}
123+
# Never share the API PVC: RWO multi-attach fails, and the worker
124+
# does not own serving state on the scale (postgres) profile.
125+
- name: data
126+
emptyDir: {}
127+
{{- if .Values.tmpVolume.enabled }}
128+
- name: tmp
129+
emptyDir:
130+
medium: Memory
131+
sizeLimit: {{ .Values.tmpVolume.sizeLimit | quote }}
132+
{{- end }}
133+
{{- with .Values.affinity }}
134+
affinity:
135+
{{- toYaml . | nindent 8 }}
136+
{{- end }}
137+
{{- with .Values.nodeSelector }}
138+
nodeSelector:
139+
{{- toYaml . | nindent 8 }}
140+
{{- end }}
141+
{{- with .Values.tolerations }}
142+
tolerations:
143+
{{- toYaml . | nindent 8 }}
144+
{{- end }}
145+
{{- end }}

‎helm/agentflow/templates/deployment.yaml‎

Lines changed: 14 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44
{{- if and (or (gt (int .Values.replicaCount) 1) (and .Values.autoscaling.enabled (gt (int .Values.autoscaling.maxReplicas) 1))) (or (ne .Values.controlPlane.store "postgres") (ne .Values.serving.backend "clickhouse")) }}
55
{{- fail "Multi-replica requires BOTH an external serving engine (serving.backend=clickhouse, ADR 0006/0007) AND an external control-plane store (controlPlane.store=postgres, ADR 0009/0010): the embedded per-pod store forks webhook/alert/outbox/usage state across replicas (duplicate deliveries, split alert history). The app-side postgres adapter shipped with ADR 0010 slice 5; until the chart profile for it lands (rollout slice 6), keep replicaCount=1 and autoscaling.maxReplicas=1." }}
66
{{- end }}
7+
{{- if and .Values.worker.enabled (ne .Values.controlPlane.store "postgres") }}
8+
{{- fail "worker.enabled requires controlPlane.store=postgres (AGENTFLOW_PROCESS_ROLE=api/worker needs a shared control plane; the embedded profile is single-process)." }}
9+
{{- end }}
710
{{- $secretName := .Values.secrets.existingSecret | default (include "agentflow.fullname" .) }}
11+
{{- $apiProcessRole := ternary "api" "" .Values.worker.enabled }}
812
apiVersion: apps/v1
913
kind: Deployment
1014
metadata:
1115
name: {{ include "agentflow.fullname" . }}
1216
labels:
1317
{{- include "agentflow.labels" . | nindent 4 }}
18+
{{- if .Values.worker.enabled }}
19+
app.kubernetes.io/component: api
20+
{{- end }}
1421
spec:
1522
replicas: {{ .Values.replicaCount }}
1623
strategy:
@@ -21,10 +28,16 @@ spec:
2128
selector:
2229
matchLabels:
2330
{{- include "agentflow.selectorLabels" . | nindent 6 }}
31+
{{- if .Values.worker.enabled }}
32+
app.kubernetes.io/component: api
33+
{{- end }}
2434
template:
2535
metadata:
2636
labels:
2737
{{- include "agentflow.selectorLabels" . | nindent 8 }}
38+
{{- if .Values.worker.enabled }}
39+
app.kubernetes.io/component: api
40+
{{- end }}
2841
{{- with .Values.podLabels }}
2942
{{- toYaml . | nindent 8 }}
3043
{{- end }}
@@ -66,89 +79,7 @@ spec:
6679
containerPort: 8000
6780
protocol: TCP
6881
env:
69-
- name: SERVING_BACKEND
70-
value: {{ .Values.serving.backend | quote }}
71-
{{- if eq .Values.serving.backend "clickhouse" }}
72-
- name: CLICKHOUSE_HOST
73-
value: {{ required "serving.clickhouse.host is required when serving.backend=clickhouse" .Values.serving.clickhouse.host | quote }}
74-
- name: CLICKHOUSE_PORT
75-
value: {{ .Values.serving.clickhouse.port | quote }}
76-
- name: CLICKHOUSE_DATABASE
77-
value: {{ .Values.serving.clickhouse.database | quote }}
78-
- name: CLICKHOUSE_USER
79-
value: {{ .Values.serving.clickhouse.user | quote }}
80-
{{- if .Values.serving.clickhouse.existingSecret }}
81-
- name: CLICKHOUSE_PASSWORD
82-
valueFrom:
83-
secretKeyRef:
84-
name: {{ .Values.serving.clickhouse.existingSecret | quote }}
85-
key: {{ .Values.serving.clickhouse.passwordKey | quote }}
86-
{{- end }}
87-
- name: CLICKHOUSE_SECURE
88-
value: {{ .Values.serving.clickhouse.secure | quote }}
89-
{{- if .Values.serving.clickhouse.tls.caSecret }}
90-
- name: CLICKHOUSE_CA_CERT
91-
value: /etc/agentflow/tls/clickhouse/{{ .Values.serving.clickhouse.tls.caKey }}
92-
{{- end }}
93-
{{- end }}
94-
# Control-plane store (ADR 0010). Set explicitly (mirrors
95-
# SERVING_BACKEND) so the rendered manifest is self-documenting; the
96-
# app treats an unset value as 'embedded'. The postgres profile also
97-
# sources its DSN from an operator-provided secret — the chart ships
98-
# no PostgreSQL service, exactly as it ships no ClickHouse.
99-
- name: AGENTFLOW_CONTROLPLANE_STORE
100-
value: {{ .Values.controlPlane.store | quote }}
101-
{{- if eq .Values.controlPlane.store "postgres" }}
102-
- name: AGENTFLOW_CONTROLPLANE_PG_DSN
103-
valueFrom:
104-
secretKeyRef:
105-
name: {{ required "controlPlane.postgres.existingSecret is required when controlPlane.store=postgres (it must hold the PostgreSQL DSN; the chart ships no PG service)" .Values.controlPlane.postgres.existingSecret | quote }}
106-
key: {{ .Values.controlPlane.postgres.dsnKey | quote }}
107-
{{- end }}
108-
- name: DUCKDB_PATH
109-
value: {{ .Values.config.duckdbPath | quote }}
110-
- name: AGENTFLOW_USAGE_DB_PATH
111-
value: {{ .Values.config.usageDbPath | quote }}
112-
- name: AGENTFLOW_API_KEYS_FILE
113-
value: {{ .Values.config.apiKeysPath | quote }}
114-
- name: AGENTFLOW_TENANTS_FILE
115-
value: {{ .Values.config.tenantsPath | quote }}
116-
- name: AGENTFLOW_SLO_FILE
117-
value: {{ .Values.config.sloPath | quote }}
118-
- name: AGENTFLOW_SECURITY_CONFIG_FILE
119-
value: {{ .Values.config.securityPath | quote }}
120-
- name: AGENTFLOW_API_VERSIONS_FILE
121-
value: {{ .Values.config.apiVersionsPath | quote }}
122-
- name: AGENTFLOW_CONTRACTS_DIR
123-
value: {{ .Values.config.contractsDir | quote }}
124-
- name: AGENTFLOW_RATE_LIMIT_RPM
125-
value: {{ .Values.config.rateLimitRpm | quote }}
126-
- name: CACHE_TTL_SECONDS
127-
value: {{ .Values.config.cacheTtlSeconds | quote }}
128-
{{- if .Values.config.profile }}
129-
- name: AGENTFLOW_PROFILE
130-
value: {{ .Values.config.profile | quote }}
131-
{{- end }}
132-
- name: AGENTFLOW_CORS_ORIGINS
133-
value: {{ .Values.config.corsOrigins | quote }}
134-
- name: AGENTFLOW_ADMIN_KEY
135-
valueFrom:
136-
secretKeyRef:
137-
name: {{ $secretName }}
138-
key: admin-key
139-
{{- if .Values.config.redisUrl }}
140-
- name: REDIS_URL
141-
value: {{ .Values.config.redisUrl | quote }}
142-
{{- end }}
143-
{{- if .Values.config.otlpEndpoint }}
144-
- name: OTEL_EXPORTER_OTLP_ENDPOINT
145-
value: {{ .Values.config.otlpEndpoint | quote }}
146-
- name: OTEL_SERVICE_NAME
147-
value: {{ .Values.config.otlpServiceName | quote }}
148-
{{- end }}
149-
{{- with .Values.extraEnv }}
150-
{{- toYaml . | nindent 12 }}
151-
{{- end }}
82+
{{- include "agentflow.containerEnv" (dict "root" . "secretName" $secretName "processRole" $apiProcessRole) | nindent 12 }}
15283
# Both probes pointed at /v1/health, which always answers 200 — its
15384
# status lives in the payload. So a pod with an unreachable serving
15485
# store stayed Ready and kept taking traffic, and liveness could never

‎helm/agentflow/templates/service.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ spec:
1717
name: http
1818
selector:
1919
{{- include "agentflow.selectorLabels" . | nindent 4 }}
20+
{{- if .Values.worker.enabled }}
21+
# Exclude worker pods (delivery loops only; same selectorLabels family).
22+
app.kubernetes.io/component: api
23+
{{- end }}

0 commit comments

Comments
 (0)