From ae0d9fb09553e76f581043ba5da9baca5802fd09 Mon Sep 17 00:00:00 2001 From: JuliaEdom Date: Thu, 16 Jul 2026 01:14:20 +0300 Subject: [PATCH 1/3] 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. --- helm/agentflow/templates/_env.tpl | 92 +++++++++++ .../templates/deployment-worker.yaml | 145 ++++++++++++++++++ helm/agentflow/templates/deployment.yaml | 97 ++---------- helm/agentflow/templates/service.yaml | 4 + helm/agentflow/values.schema.json | 22 ++- helm/agentflow/values.yaml | 19 ++- tests/unit/test_helm_values_contract.py | 46 ++++++ 7 files changed, 335 insertions(+), 90 deletions(-) create mode 100644 helm/agentflow/templates/_env.tpl create mode 100644 helm/agentflow/templates/deployment-worker.yaml diff --git a/helm/agentflow/templates/_env.tpl b/helm/agentflow/templates/_env.tpl new file mode 100644 index 00000000..b13dc145 --- /dev/null +++ b/helm/agentflow/templates/_env.tpl @@ -0,0 +1,92 @@ +{{/* +Common container env for API and worker Deployments. +Caller: include "agentflow.containerEnv" (dict "root" . "secretName" $secretName "processRole" "api") +processRole may be "" (omit AGENTFLOW_PROCESS_ROLE — app default 'all'). +*/}} +{{- define "agentflow.containerEnv" -}} +{{- $root := .root -}} +{{- $secretName := .secretName -}} +{{- $processRole := .processRole | default "" -}} +- name: SERVING_BACKEND + value: {{ $root.Values.serving.backend | quote }} +{{- if eq $root.Values.serving.backend "clickhouse" }} +- name: CLICKHOUSE_HOST + value: {{ required "serving.clickhouse.host is required when serving.backend=clickhouse" $root.Values.serving.clickhouse.host | quote }} +- name: CLICKHOUSE_PORT + value: {{ $root.Values.serving.clickhouse.port | quote }} +- name: CLICKHOUSE_DATABASE + value: {{ $root.Values.serving.clickhouse.database | quote }} +- name: CLICKHOUSE_USER + value: {{ $root.Values.serving.clickhouse.user | quote }} +{{- if $root.Values.serving.clickhouse.existingSecret }} +- name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $root.Values.serving.clickhouse.existingSecret | quote }} + key: {{ $root.Values.serving.clickhouse.passwordKey | quote }} +{{- end }} +- name: CLICKHOUSE_SECURE + value: {{ $root.Values.serving.clickhouse.secure | quote }} +{{- if $root.Values.serving.clickhouse.tls.caSecret }} +- name: CLICKHOUSE_CA_CERT + value: /etc/agentflow/tls/clickhouse/{{ $root.Values.serving.clickhouse.tls.caKey }} +{{- end }} +{{- end }} +- name: AGENTFLOW_CONTROLPLANE_STORE + value: {{ $root.Values.controlPlane.store | quote }} +{{- if eq $root.Values.controlPlane.store "postgres" }} +- name: AGENTFLOW_CONTROLPLANE_PG_DSN + valueFrom: + secretKeyRef: + 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 }} + key: {{ $root.Values.controlPlane.postgres.dsnKey | quote }} +{{- end }} +{{- if $processRole }} +- name: AGENTFLOW_PROCESS_ROLE + value: {{ $processRole | quote }} +{{- end }} +- name: DUCKDB_PATH + value: {{ $root.Values.config.duckdbPath | quote }} +- name: AGENTFLOW_USAGE_DB_PATH + value: {{ $root.Values.config.usageDbPath | quote }} +- name: AGENTFLOW_API_KEYS_FILE + value: {{ $root.Values.config.apiKeysPath | quote }} +- name: AGENTFLOW_TENANTS_FILE + value: {{ $root.Values.config.tenantsPath | quote }} +- name: AGENTFLOW_SLO_FILE + value: {{ $root.Values.config.sloPath | quote }} +- name: AGENTFLOW_SECURITY_CONFIG_FILE + value: {{ $root.Values.config.securityPath | quote }} +- name: AGENTFLOW_API_VERSIONS_FILE + value: {{ $root.Values.config.apiVersionsPath | quote }} +- name: AGENTFLOW_CONTRACTS_DIR + value: {{ $root.Values.config.contractsDir | quote }} +- name: AGENTFLOW_RATE_LIMIT_RPM + value: {{ $root.Values.config.rateLimitRpm | quote }} +- name: CACHE_TTL_SECONDS + value: {{ $root.Values.config.cacheTtlSeconds | quote }} +{{- if $root.Values.config.profile }} +- name: AGENTFLOW_PROFILE + value: {{ $root.Values.config.profile | quote }} +{{- end }} +- name: AGENTFLOW_CORS_ORIGINS + value: {{ $root.Values.config.corsOrigins | quote }} +- name: AGENTFLOW_ADMIN_KEY + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: admin-key +{{- if $root.Values.config.redisUrl }} +- name: REDIS_URL + value: {{ $root.Values.config.redisUrl | quote }} +{{- end }} +{{- if $root.Values.config.otlpEndpoint }} +- name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ $root.Values.config.otlpEndpoint | quote }} +- name: OTEL_SERVICE_NAME + value: {{ $root.Values.config.otlpServiceName | quote }} +{{- end }} +{{- with $root.Values.extraEnv }} +{{- toYaml . }} +{{- end }} +{{- end -}} diff --git a/helm/agentflow/templates/deployment-worker.yaml b/helm/agentflow/templates/deployment-worker.yaml new file mode 100644 index 00000000..89b46cf7 --- /dev/null +++ b/helm/agentflow/templates/deployment-worker.yaml @@ -0,0 +1,145 @@ +{{- if .Values.worker.enabled }} +{{- if ne .Values.controlPlane.store "postgres" }} +{{- fail "worker.enabled requires controlPlane.store=postgres (AGENTFLOW_PROCESS_ROLE=api/worker needs a shared control plane; the embedded profile is single-process)." }} +{{- end }} +{{- $secretName := .Values.secrets.existingSecret | default (include "agentflow.fullname" .) }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "agentflow.fullname" . }}-worker + labels: + {{- include "agentflow.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + # Delivery loops claim rows in PostgreSQL; keep replicaCount small (default 1). + # Scaling workers multiplies claim concurrency, not request capacity. + replicas: {{ .Values.worker.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "agentflow.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + labels: + {{- include "agentflow.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if .Values.secrets.create }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "agentflow.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: agentflow-worker + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + command: + - uvicorn + - src.serving.api.main:app + - --host + - 0.0.0.0 + - --port + - "8000" + ports: + - name: http + containerPort: 8000 + protocol: TCP + env: + {{- include "agentflow.containerEnv" (dict "root" . "secretName" $secretName "processRole" "worker") | nindent 12 }} + # Worker does not take Service traffic; probes stay dependency-light + # so a serving-store blip does not thrash delivery. + readinessProbe: + httpGet: + path: /health/live + port: http + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + livenessProbe: + httpGet: + path: /health/live + port: http + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + resources: + {{- toYaml (.Values.worker.resources | default .Values.resources) | nindent 12 }} + volumeMounts: + - name: data + mountPath: {{ .Values.persistence.mountPath | quote }} + - name: config + mountPath: /etc/agentflow/config + readOnly: true + - name: secrets + mountPath: /etc/agentflow/secret + readOnly: true + {{- if and (eq .Values.serving.backend "clickhouse") .Values.serving.clickhouse.tls.caSecret }} + - name: clickhouse-ca + mountPath: /etc/agentflow/tls/clickhouse + readOnly: true + {{- end }} + {{- if .Values.tmpVolume.enabled }} + - name: tmp + mountPath: /tmp + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "agentflow.fullname" . }} + - name: secrets + secret: + secretName: {{ $secretName }} + {{- if and (eq .Values.serving.backend "clickhouse") .Values.serving.clickhouse.tls.caSecret }} + - name: clickhouse-ca + secret: + secretName: {{ .Values.serving.clickhouse.tls.caSecret | quote }} + {{- end }} + # Never share the API PVC: RWO multi-attach fails, and the worker + # does not own serving state on the scale (postgres) profile. + - name: data + emptyDir: {} + {{- if .Values.tmpVolume.enabled }} + - name: tmp + emptyDir: + medium: Memory + sizeLimit: {{ .Values.tmpVolume.sizeLimit | quote }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/helm/agentflow/templates/deployment.yaml b/helm/agentflow/templates/deployment.yaml index 36171f2e..247de310 100644 --- a/helm/agentflow/templates/deployment.yaml +++ b/helm/agentflow/templates/deployment.yaml @@ -4,13 +4,20 @@ {{- 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")) }} {{- 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." }} {{- end }} +{{- if and .Values.worker.enabled (ne .Values.controlPlane.store "postgres") }} +{{- fail "worker.enabled requires controlPlane.store=postgres (AGENTFLOW_PROCESS_ROLE=api/worker needs a shared control plane; the embedded profile is single-process)." }} +{{- end }} {{- $secretName := .Values.secrets.existingSecret | default (include "agentflow.fullname" .) }} +{{- $apiProcessRole := ternary "api" "" .Values.worker.enabled }} apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "agentflow.fullname" . }} labels: {{- include "agentflow.labels" . | nindent 4 }} + {{- if .Values.worker.enabled }} + app.kubernetes.io/component: api + {{- end }} spec: replicas: {{ .Values.replicaCount }} strategy: @@ -21,10 +28,16 @@ spec: selector: matchLabels: {{- include "agentflow.selectorLabels" . | nindent 6 }} + {{- if .Values.worker.enabled }} + app.kubernetes.io/component: api + {{- end }} template: metadata: labels: {{- include "agentflow.selectorLabels" . | nindent 8 }} + {{- if .Values.worker.enabled }} + app.kubernetes.io/component: api + {{- end }} {{- with .Values.podLabels }} {{- toYaml . | nindent 8 }} {{- end }} @@ -66,89 +79,7 @@ spec: containerPort: 8000 protocol: TCP env: - - name: SERVING_BACKEND - value: {{ .Values.serving.backend | quote }} - {{- if eq .Values.serving.backend "clickhouse" }} - - name: CLICKHOUSE_HOST - value: {{ required "serving.clickhouse.host is required when serving.backend=clickhouse" .Values.serving.clickhouse.host | quote }} - - name: CLICKHOUSE_PORT - value: {{ .Values.serving.clickhouse.port | quote }} - - name: CLICKHOUSE_DATABASE - value: {{ .Values.serving.clickhouse.database | quote }} - - name: CLICKHOUSE_USER - value: {{ .Values.serving.clickhouse.user | quote }} - {{- if .Values.serving.clickhouse.existingSecret }} - - name: CLICKHOUSE_PASSWORD - valueFrom: - secretKeyRef: - name: {{ .Values.serving.clickhouse.existingSecret | quote }} - key: {{ .Values.serving.clickhouse.passwordKey | quote }} - {{- end }} - - name: CLICKHOUSE_SECURE - value: {{ .Values.serving.clickhouse.secure | quote }} - {{- if .Values.serving.clickhouse.tls.caSecret }} - - name: CLICKHOUSE_CA_CERT - value: /etc/agentflow/tls/clickhouse/{{ .Values.serving.clickhouse.tls.caKey }} - {{- end }} - {{- end }} - # Control-plane store (ADR 0010). Set explicitly (mirrors - # SERVING_BACKEND) so the rendered manifest is self-documenting; the - # app treats an unset value as 'embedded'. The postgres profile also - # sources its DSN from an operator-provided secret — the chart ships - # no PostgreSQL service, exactly as it ships no ClickHouse. - - name: AGENTFLOW_CONTROLPLANE_STORE - value: {{ .Values.controlPlane.store | quote }} - {{- if eq .Values.controlPlane.store "postgres" }} - - name: AGENTFLOW_CONTROLPLANE_PG_DSN - valueFrom: - secretKeyRef: - 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 }} - key: {{ .Values.controlPlane.postgres.dsnKey | quote }} - {{- end }} - - name: DUCKDB_PATH - value: {{ .Values.config.duckdbPath | quote }} - - name: AGENTFLOW_USAGE_DB_PATH - value: {{ .Values.config.usageDbPath | quote }} - - name: AGENTFLOW_API_KEYS_FILE - value: {{ .Values.config.apiKeysPath | quote }} - - name: AGENTFLOW_TENANTS_FILE - value: {{ .Values.config.tenantsPath | quote }} - - name: AGENTFLOW_SLO_FILE - value: {{ .Values.config.sloPath | quote }} - - name: AGENTFLOW_SECURITY_CONFIG_FILE - value: {{ .Values.config.securityPath | quote }} - - name: AGENTFLOW_API_VERSIONS_FILE - value: {{ .Values.config.apiVersionsPath | quote }} - - name: AGENTFLOW_CONTRACTS_DIR - value: {{ .Values.config.contractsDir | quote }} - - name: AGENTFLOW_RATE_LIMIT_RPM - value: {{ .Values.config.rateLimitRpm | quote }} - - name: CACHE_TTL_SECONDS - value: {{ .Values.config.cacheTtlSeconds | quote }} - {{- if .Values.config.profile }} - - name: AGENTFLOW_PROFILE - value: {{ .Values.config.profile | quote }} - {{- end }} - - name: AGENTFLOW_CORS_ORIGINS - value: {{ .Values.config.corsOrigins | quote }} - - name: AGENTFLOW_ADMIN_KEY - valueFrom: - secretKeyRef: - name: {{ $secretName }} - key: admin-key - {{- if .Values.config.redisUrl }} - - name: REDIS_URL - value: {{ .Values.config.redisUrl | quote }} - {{- end }} - {{- if .Values.config.otlpEndpoint }} - - name: OTEL_EXPORTER_OTLP_ENDPOINT - value: {{ .Values.config.otlpEndpoint | quote }} - - name: OTEL_SERVICE_NAME - value: {{ .Values.config.otlpServiceName | quote }} - {{- end }} - {{- with .Values.extraEnv }} - {{- toYaml . | nindent 12 }} - {{- end }} + {{- include "agentflow.containerEnv" (dict "root" . "secretName" $secretName "processRole" $apiProcessRole) | nindent 12 }} # Both probes pointed at /v1/health, which always answers 200 — its # status lives in the payload. So a pod with an unreachable serving # store stayed Ready and kept taking traffic, and liveness could never diff --git a/helm/agentflow/templates/service.yaml b/helm/agentflow/templates/service.yaml index 89034d32..e2008380 100644 --- a/helm/agentflow/templates/service.yaml +++ b/helm/agentflow/templates/service.yaml @@ -17,3 +17,7 @@ spec: name: http selector: {{- include "agentflow.selectorLabels" . | nindent 4 }} + {{- if .Values.worker.enabled }} + # Exclude worker pods (delivery loops only; same selectorLabels family). + app.kubernetes.io/component: api + {{- end }} diff --git a/helm/agentflow/values.schema.json b/helm/agentflow/values.schema.json index 6a064cf4..83a6c94c 100644 --- a/helm/agentflow/values.schema.json +++ b/helm/agentflow/values.schema.json @@ -30,7 +30,8 @@ "networkPolicy", "serving", "provision", - "controlPlane" + "controlPlane", + "worker" ], "allOf": [ { @@ -509,6 +510,25 @@ } } }, + "worker": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "replicaCount"], + "description": "Optional dedicated Deployment for delivery loops (AGENTFLOW_PROCESS_ROLE=worker). When enabled the API Deployment is role=api and the Service selects only component=api. Requires controlPlane.store=postgres.", + "properties": { + "enabled": { + "type": "boolean" + }, + "replicaCount": { + "type": "integer", + "minimum": 1 + }, + "resources": { + "type": "object", + "description": "Optional resource overrides for the worker container; empty object falls back to top-level resources." + } + } + }, "config": { "type": "object", "additionalProperties": false, diff --git a/helm/agentflow/values.yaml b/helm/agentflow/values.yaml index 0b0af86a..30d0a371 100644 --- a/helm/agentflow/values.yaml +++ b/helm/agentflow/values.yaml @@ -180,22 +180,29 @@ provision: # PostgreSQL service (mirroring ClickHouse) — the operator provides the DSN in # a Secret referenced by postgres.existingSecret / postgres.dsnKey. # -# Pool shape and process role (audit P1-1) tune via extraEnv: +# Pool shape (audit P1-1) tune via extraEnv: # AGENTFLOW_CONTROLPLANE_PG_POOL_MIN / _MAX (default 1/10) — the per-pod # connection budget; _MAX * replicas must stay under the server's # max_connections. # AGENTFLOW_CONTROLPLANE_PG_POOL_TIMEOUT_SECONDS (default 10) — checkout wait. -# AGENTFLOW_PROCESS_ROLE — 'api' pods serve requests and run NO delivery -# loops; ONE 'worker' pod runs webhook/alert dispatch + outbox. Default -# 'all' is the single-pod shape. A dedicated worker Deployment template -# is a follow-up; until then run a second release (replicaCount: 1, -# extraEnv role=worker) against the same DSN. +# +# Process role split (audit P1-1): leave worker.enabled=false for the single-pod +# shape (AGENTFLOW_PROCESS_ROLE defaults to 'all'). With worker.enabled=true the +# API Deployment is role=api, a dedicated worker Deployment is role=worker, and +# the Service selects only component=api. Requires controlPlane.store=postgres. controlPlane: store: embedded postgres: existingSecret: "" dsnKey: controlplane-pg-dsn +# Dedicated delivery-loop Deployment (webhook/alert/outbox). Off by default. +worker: + enabled: false + replicaCount: 1 + # Empty object falls back to top-level resources. + resources: {} + config: # Deployment profile handed to the app as AGENTFLOW_PROFILE (audit P2-3). # "" keeps the app default (dev; demo when AGENTFLOW_DEMO_MODE=true). diff --git a/tests/unit/test_helm_values_contract.py b/tests/unit/test_helm_values_contract.py index 7dfcdd94..1697893a 100644 --- a/tests/unit/test_helm_values_contract.py +++ b/tests/unit/test_helm_values_contract.py @@ -426,3 +426,49 @@ def test_serving_clickhouse_tls_render_is_first_class(): assert "bogus" in bogus_output, bogus_output assert "additional propert" in bogus_output.lower(), bogus_output assert "not allowed" in bogus_output, bogus_output + + +def test_worker_defaults_off_and_omits_process_role(): + """Single-pod shape: no worker Deployment, no AGENTFLOW_PROCESS_ROLE.""" + values = _load_yaml(CHART_PATH / "values.yaml") + assert values["worker"]["enabled"] is False + assert values["worker"]["replicaCount"] == 1 + + result = _run_helm_template() + output = _combined_output(result) + assert result.returncode == 0, output + assert "AGENTFLOW_PROCESS_ROLE" not in output + assert "agentflow-worker" not in output + assert "app.kubernetes.io/component: worker" not in output + + +def test_worker_enabled_requires_postgres_control_plane(): + result = _run_helm_template("--set", "worker.enabled=true") + output = _combined_output(result) + assert result.returncode != 0, output + assert "worker.enabled requires controlPlane.store=postgres" in output + + +def test_worker_enabled_splits_api_and_worker_roles(): + """Scale profile + worker: API role=api, worker Deployment role=worker, + Service selects component=api only (worker not in Service endpoints).""" + result = _run_helm_template( + *_scale_profile_args( + "--set", + "worker.enabled=true", + "--set", + "worker.replicaCount=1", + "--set", + "replicaCount=2", + ) + ) + output = _combined_output(result) + assert result.returncode == 0, output + assert "name: agentflow-worker" in output or "name: release-name-agentflow-worker" in output or "-worker\n" in output + assert 'name: AGENTFLOW_PROCESS_ROLE\n value: "api"' in output + assert 'name: AGENTFLOW_PROCESS_ROLE\n value: "worker"' in output + # Service selector narrows to API when worker is on. + assert "app.kubernetes.io/component: api" in output + assert "app.kubernetes.io/component: worker" in output + # Worker must not share the API PVC (RWO multi-attach). + assert output.count("persistentVolumeClaim:") <= 1 From b9130b49595cfcd12ce0ab2d44e22b4c13ec6643 Mon Sep 17 00:00:00 2001 From: JuliaEdom Date: Thu, 16 Jul 2026 01:20:20 +0300 Subject: [PATCH 2/3] style: ruff format helm worker contract tests --- tests/unit/test_helm_values_contract.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_helm_values_contract.py b/tests/unit/test_helm_values_contract.py index 1697893a..97f297ca 100644 --- a/tests/unit/test_helm_values_contract.py +++ b/tests/unit/test_helm_values_contract.py @@ -464,7 +464,11 @@ def test_worker_enabled_splits_api_and_worker_roles(): ) output = _combined_output(result) assert result.returncode == 0, output - assert "name: agentflow-worker" in output or "name: release-name-agentflow-worker" in output or "-worker\n" in output + assert ( + "name: agentflow-worker" in output + or "name: release-name-agentflow-worker" in output + or "-worker\n" in output + ) assert 'name: AGENTFLOW_PROCESS_ROLE\n value: "api"' in output assert 'name: AGENTFLOW_PROCESS_ROLE\n value: "worker"' in output # Service selector narrows to API when worker is on. From 2c6537a603c3f332d07cf93b7893d2c4623ec7c8 Mon Sep 17 00:00:00 2001 From: JuliaEdom Date: Thu, 16 Jul 2026 01:28:59 +0300 Subject: [PATCH 3/3] fix(helm): newline before extraEnv in shared container env --- helm/agentflow/templates/_env.tpl | 3 ++- tests/unit/test_helm_values_contract.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/helm/agentflow/templates/_env.tpl b/helm/agentflow/templates/_env.tpl index b13dc145..1df61c7a 100644 --- a/helm/agentflow/templates/_env.tpl +++ b/helm/agentflow/templates/_env.tpl @@ -87,6 +87,7 @@ processRole may be "" (omit AGENTFLOW_PROCESS_ROLE — app default 'all'). value: {{ $root.Values.config.otlpServiceName | quote }} {{- end }} {{- with $root.Values.extraEnv }} -{{- toYaml . }} +{{/* Leading newline required: {{- toYaml }} would glue onto the previous value. */}} +{{ toYaml . }} {{- end }} {{- end -}} diff --git a/tests/unit/test_helm_values_contract.py b/tests/unit/test_helm_values_contract.py index 97f297ca..cc6f60b3 100644 --- a/tests/unit/test_helm_values_contract.py +++ b/tests/unit/test_helm_values_contract.py @@ -428,6 +428,21 @@ def test_serving_clickhouse_tls_render_is_first_class(): assert "not allowed" in bogus_output, bogus_output +def test_staging_values_render_with_extra_env_after_redis(): + """Staging overlays set redisUrl + extraEnv; the shared env template must + not glue the last fixed entry onto the first extraEnv list item (YAML + 'block sequence entries are not allowed in this context').""" + staging = PROJECT_ROOT / "k8s" / "staging" / "values-staging.yaml" + result = _run_helm_template("-f", str(staging)) + output = _combined_output(result) + assert result.returncode == 0, output + assert "AGENTFLOW_WEBHOOKS_FILE" in output + assert "AGENTFLOW_SEED_ON_BOOT" in output + # Must be a separate sequence entry, not glued to the REDIS_URL value. + assert 'value: "redis://' in output + assert '/0"- name:' not in output + + def test_worker_defaults_off_and_omits_process_role(): """Single-pod shape: no worker Deployment, no AGENTFLOW_PROCESS_ROLE.""" values = _load_yaml(CHART_PATH / "values.yaml")