diff --git a/braintrust/README.md b/braintrust/README.md index 4e0395e..59b2fd9 100644 --- a/braintrust/README.md +++ b/braintrust/README.md @@ -217,6 +217,52 @@ Size the request for the pod's full local-storage usage: When you enable `tmpVolume`, make sure the `ephemeralStorage.request` still covers that extra space. +## API workload isolation + +`api.workloadIsolation.enabled` creates fixed-capacity `braintrust-api-ingest` +and `braintrust-api-background` Deployments and Services alongside the existing +default `braintrust-api` pool. The pools share the same image and base +configuration, while allowing independent replicas, resources, probes, rollout +settings, environment overrides, topology spreading, and disruption budgets. + +The product-owned route contract is defined in +[`files/contracts/api-workload-isolation-routes.yaml`](files/contracts/api-workload-isolation-routes.yaml). +An ingress or gateway must preserve `braintrust-api` as its default backend and +route these paths: + +| Pool | Paths | +| --- | --- | +| `braintrust-api` (default) | All requests not matched by an explicit ingest or background route | +| `braintrust-api-ingest` | `POST /logs3`, `POST /otel/v1/traces`, `POST /attachment`, `POST /attachment/status` | +| `braintrust-api-background` | `POST /v1/eval`, `POST /v1/eval/*`, `POST /function/eval`, `POST /function/sandbox`, `POST /function/use`, `POST /function/invoke-async-batch`, `POST /function/insert-functions`, `POST /automation/logs/trigger`; all methods for `/v1/proxy/chat/completions`, `/v1/proxy/responses` | + +By default, Brainstore's internal `BRAINSTORE_AI_PROXY_URL` targets the +background Service while isolation is enabled. For an existing deployment, +first create the pools with +`api.workloadIsolation.brainstoreAiProxyToBackground: false`, and keep public +paths on the default Service. Verify the background pool is ready, then route +the classified public paths and set `brainstoreAiProxyToBackground: true` in a +later release. To roll back, first return both the public paths and +`brainstoreAiProxyToBackground` to the default API Service and verify it is +serving them. Only then disable workload isolation in the chart; the chart +cannot update an external ingress or gateway on its own. + +When using the chart-managed Istio `VirtualService`, set +`virtualService.workloadIsolation.enabled: true` during the second release. +The chart then renders the same product-owned route contract ahead of the +existing `virtualService.http` rules, which remain available for fallback or +custom routing of non-classified paths. The product-owned routes take +precedence, so do not use `virtualService.http` to override a classified path. +This option requires both `virtualService.enabled: true` and +`api.workloadIsolation.enabled: true`. It preserves the AWS route methods: +ingest, eval, function, and automation routes match `POST`; proxy routes match +all methods. GKE Ingress cannot route by method, so its equivalent integration +classifies matching paths for all methods. + +This feature does not enable autoscaling. Configure fixed replica counts under +`api.replicas`, `api.workloadIsolation.ingest.replicas`, and +`api.workloadIsolation.background.replicas`. + ## Testing This Helm chart includes comprehensive automated unit tests. diff --git a/braintrust/files/contracts/api-workload-isolation-routes.yaml b/braintrust/files/contracts/api-workload-isolation-routes.yaml new file mode 100644 index 0000000..d37f441 --- /dev/null +++ b/braintrust/files/contracts/api-workload-isolation-routes.yaml @@ -0,0 +1,55 @@ +# Canonical public route ownership for API workload isolation. +# +# This is product-controlled rather than a chart value: changing it changes +# which API pool receives customer traffic. Ingress and gateway integrations +# must implement this contract and retain the default API Service for all +# unmatched requests. +version: v1 +pools: + ingest: + routes: + - path: /logs3 + pathType: exact + method: POST + - path: /otel/v1/traces + pathType: exact + method: POST + - path: /attachment + pathType: exact + method: POST + - path: /attachment/status + pathType: exact + method: POST + background: + routes: + # Keep this distinct from the trailing-slash prefix. Istio URI + # prefixes are string prefixes, so /v1/eval would also match + # /v1/evaluate. + - path: /v1/eval + pathType: exact + method: POST + - path: /v1/eval/ + pathType: prefix + method: POST + - path: /function/eval + pathType: exact + method: POST + - path: /function/sandbox + pathType: exact + method: POST + - path: /function/use + pathType: exact + method: POST + - path: /function/invoke-async-batch + pathType: exact + method: POST + - path: /function/insert-functions + pathType: exact + method: POST + - path: /automation/logs/trigger + pathType: exact + method: POST + - path: /v1/proxy/chat/completions + pathType: exact + - path: /v1/proxy/responses + pathType: exact diff --git a/braintrust/templates/_api-deployment.tpl b/braintrust/templates/_api-deployment.tpl new file mode 100644 index 0000000..8e2d39e --- /dev/null +++ b/braintrust/templates/_api-deployment.tpl @@ -0,0 +1,232 @@ +{{/* Render one API Deployment from a merged pool configuration. */}} +{{- define "braintrust.apiDeployment" -}} +{{- $root := .root -}} +{{- $api := .api -}} +{{- $role := .role -}} +{{- $customCA := $api.customCA -}} +{{- $customCAMountPath := "" -}} +{{- $customCAFilename := "" -}} +{{- $customCASecretName := "" -}} +{{- $customCASecretKey := "" -}} +{{- if $customCA.enabled -}} +{{- $customCAMountPath = required "api.customCA.mountPath is required when api.customCA.enabled is true" $customCA.mountPath -}} +{{- $customCAFilename = required "api.customCA.filename is required when api.customCA.enabled is true" $customCA.filename -}} +{{- $customCASecretName = required "api.customCA.secretName is required when api.customCA.enabled is true" $customCA.secretName -}} +{{- $customCASecretKey = required "api.customCA.secretKey is required when api.customCA.enabled is true" $customCA.secretKey -}} +{{- end -}} +{{- $poolLabels := dict -}} +{{- if or $root.Values.api.workloadIsolation.enabled (ne $role "default") -}} +{{- $_ := set $poolLabels "braintrust.dev/api-pool" $role -}} +{{- end -}} +{{- $resourceLabels := mergeOverwrite (deepCopy $root.Values.global.labels) (deepCopy $api.labels) $poolLabels -}} +{{- $podLabels := mergeOverwrite (deepCopy $root.Values.global.labels) (deepCopy $api.labels) (deepCopy $api.podLabels) (dict "app" $api.name) $poolLabels -}} +{{- if eq $root.Values.cloud "azure" -}} +{{- $_ := set $podLabels "azure.workload.identity/use" "true" -}} +{{- end -}} +{{- if and (eq $root.Values.cloud "google") $api.enableGcsAuth -}} +{{- $_ := set $podLabels "gke-workload-identity/use" "true" -}} +{{- end -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $api.name }} + namespace: {{ include "braintrust.namespace" $root }} + {{- with $resourceLabels }} + labels: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $api.annotations.deployment }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: {{ $api.replicas }} + strategy: + type: {{ $api.strategy.type }} + {{- with $api.strategy.rollingUpdate }} + rollingUpdate: + {{- toYaml . | nindent 6 }} + {{- end }} + selector: + matchLabels: + app: {{ $api.name }} + template: + metadata: + labels: + {{- with $podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + checksum/config: {{ include (print $root.Template.BasePath "/api-configmap.yaml") $root | sha256sum }} + {{- if and (eq $root.Values.cloud "google") $api.enableGcsAuth }} + iam.gke.io/gcp-service-account: {{ required "api.serviceAccount.googleServiceAccount is required when api.enableGcsAuth is true" $api.serviceAccount.googleServiceAccount }} + {{- end }} + {{- with $api.annotations.pod }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ $api.serviceAccount.name }} + {{- with $api.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if $api.topologySpread.enabled }} + topologySpreadConstraints: + - maxSkew: {{ $api.topologySpread.maxSkew }} + topologyKey: {{ $api.topologySpread.topologyKey | quote }} + whenUnsatisfiable: {{ $api.topologySpread.whenUnsatisfiable }} + labelSelector: + matchLabels: + app: {{ $api.name }} + {{- end }} + {{- with $api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: api + image: "{{ $api.image.repository }}:{{ $api.image.tag }}" + imagePullPolicy: {{ $api.image.pullPolicy }} + {{- with $api.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - containerPort: {{ $api.service.port }} + resources: + {{- toYaml $api.resources | nindent 12 }} + {{- with $api.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with $api.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - configMapRef: + name: {{ $root.Values.api.name }} + env: + - name: PG_URL + valueFrom: + secretKeyRef: + name: braintrust-secrets + key: PG_URL + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: braintrust-secrets + key: REDIS_URL + - name: FUNCTION_SECRET_KEY + valueFrom: + secretKeyRef: + name: braintrust-secrets + key: FUNCTION_SECRET_KEY + - name: BRAINSTORE_LICENSE_KEY + valueFrom: + secretKeyRef: + name: braintrust-secrets + key: BRAINSTORE_LICENSE_KEY + {{- if eq $root.Values.cloud "azure" }} + - name: AZURE_STORAGE_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: braintrust-secrets + key: AZURE_STORAGE_CONNECTION_STRING + {{- end }} + {{- if and (eq $root.Values.cloud "google") (not $api.enableGcsAuth) }} + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: braintrust-secrets + key: GCS_ACCESS_KEY_ID + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: braintrust-secrets + key: GCS_SECRET_ACCESS_KEY + {{- end }} + - name: TS_API_HEALTHSERVER_HOST + value: {{ $api.healthServer.host | quote }} + - name: TS_API_HEALTHSERVER_PORT + value: {{ $api.healthServer.port | quote }} + {{- if $customCA.enabled }} + {{- $customCAPath := printf "%s/%s" $customCAMountPath $customCAFilename }} + - name: NODE_EXTRA_CA_CERTS + value: {{ $customCAPath | quote }} + - name: REQUESTS_CA_BUNDLE + value: {{ $customCAPath | quote }} + - name: SSL_CERT_FILE + value: {{ $customCAPath | quote }} + - name: CURL_CA_BUNDLE + value: {{ $customCAPath | quote }} + - name: AWS_CA_BUNDLE + value: {{ $customCAPath | quote }} + - name: PIP_CERT + value: {{ $customCAPath | quote }} + {{- end }} + {{- with $api.extraEnvVars }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if or $api.tmpVolume.enabled (and (eq $root.Values.cloud "azure") $root.Values.azure.enableAzureKeyVaultDriver) $customCA.enabled }} + volumeMounts: + {{- if $api.tmpVolume.enabled }} + - name: tmp-volume + mountPath: /tmp + {{- end }} + {{- if and (eq $root.Values.cloud "azure") $root.Values.azure.enableAzureKeyVaultDriver }} + - name: secrets-store-inline + mountPath: "/mnt/secrets-store" + readOnly: true + {{- end }} + {{- if $customCA.enabled }} + - name: custom-ca-bundle + mountPath: {{ $customCAMountPath | quote }} + readOnly: true + {{- end }} + {{- end }} + {{- with $api.extraContainers }} + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- if or $api.tmpVolume.enabled (and (eq $root.Values.cloud "azure") $root.Values.azure.enableAzureKeyVaultDriver) $customCA.enabled $api.extraVolumes }} + {{- if $api.tmpVolume.enabled }} + - name: tmp-volume + emptyDir: + {{- if $api.tmpVolume.sizeLimit }} + sizeLimit: {{ $api.tmpVolume.sizeLimit | quote }} + {{- else }} + {} + {{- end }} + {{- end }} + {{- if and (eq $root.Values.cloud "azure") $root.Values.azure.enableAzureKeyVaultDriver }} + - name: secrets-store-inline + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: {{ $root.Values.azure.keyVaultName }} + {{- end }} + {{- if $customCA.enabled }} + - name: custom-ca-bundle + secret: + secretName: {{ $customCASecretName | quote }} + items: + - key: {{ $customCASecretKey | quote }} + path: {{ $customCAFilename | quote }} + {{- end }} + {{- with $api.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- else }} + [] + {{- end }} +{{- end -}} diff --git a/braintrust/templates/_api-service.tpl b/braintrust/templates/_api-service.tpl new file mode 100644 index 0000000..b8a2831 --- /dev/null +++ b/braintrust/templates/_api-service.tpl @@ -0,0 +1,33 @@ +{{/* Render one API Service from a merged pool configuration. */}} +{{- define "braintrust.apiService" -}} +{{- $root := .root -}} +{{- $api := .api -}} +{{- $role := .role -}} +{{- $poolLabels := dict -}} +{{- if or $root.Values.api.workloadIsolation.enabled (ne $role "default") -}} +{{- $_ := set $poolLabels "braintrust.dev/api-pool" $role -}} +{{- end -}} +{{- $resourceLabels := mergeOverwrite (deepCopy $root.Values.global.labels) (deepCopy $api.labels) $poolLabels -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $api.service.name | default $api.name }} + namespace: {{ include "braintrust.namespace" $root }} + {{- with $resourceLabels }} + labels: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $api.annotations.service }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + app: {{ $api.name }} + ports: + - name: {{ $api.service.portName }} + protocol: TCP + port: {{ $api.service.port }} + targetPort: {{ $api.service.port }} + type: {{ $api.service.type }} +{{- end -}} diff --git a/braintrust/templates/_helpers.tpl b/braintrust/templates/_helpers.tpl index d27e28f..d18b731 100644 --- a/braintrust/templates/_helpers.tpl +++ b/braintrust/templates/_helpers.tpl @@ -32,6 +32,82 @@ Internal cluster URL for the API service. http://{{ .Values.api.service.name | default .Values.api.name }}.{{ include "braintrust.namespace" . }}:{{ .Values.api.service.port }} {{- end -}} +{{/* +Build one API pool from the shared api values plus pool-specific overrides. +Pool extraEnvVars are appended so shared provider configuration is retained. +*/}} +{{- define "braintrust.apiPoolConfig" -}} +{{- $base := deepCopy .root.Values.api -}} +{{- $_ := unset $base "workloadIsolation" -}} +{{- $overrides := deepCopy (.overrides | default dict) -}} +{{- $baseExtraEnvVars := get $base "extraEnvVars" | default (list) -}} +{{- $poolExtraEnvVars := get $overrides "extraEnvVars" | default (list) -}} +{{- $_ := unset $overrides "extraEnvVars" -}} +{{- $pool := mergeOverwrite $base $overrides -}} +{{- $_ := set $pool "extraEnvVars" (concat $baseExtraEnvVars $poolExtraEnvVars) -}} +{{- toYaml $pool -}} +{{- end -}} + +{{/* +Return the API pools rendered by the chart. The default pool always exists; +ingest and background are added only when workload isolation is enabled. +*/}} +{{- define "braintrust.apiPools" -}} +{{- $default := include "braintrust.apiPoolConfig" (dict "root" . "overrides" (dict)) | fromYaml -}} +{{- $pools := list (dict "role" "default" "config" $default) -}} +{{- if .Values.api.workloadIsolation.enabled -}} +{{- $ingest := include "braintrust.apiPoolConfig" (dict "root" . "overrides" .Values.api.workloadIsolation.ingest) | fromYaml -}} +{{- $background := include "braintrust.apiPoolConfig" (dict "root" . "overrides" .Values.api.workloadIsolation.background) | fromYaml -}} +{{- $pools = append $pools (dict "role" "ingest" "config" $ingest) -}} +{{- $pools = append $pools (dict "role" "background" "config" $background) -}} +{{- end -}} +{{- toYaml $pools -}} +{{- end -}} + +{{/* +Render the product-owned workload-isolation routes for an Istio VirtualService. +The static route contract is packaged with the chart at +files/contracts/api-workload-isolation-routes.yaml. User-supplied virtualService.http +routes are rendered after these routes as custom fallback behavior. +*/}} +{{- define "braintrust.apiWorkloadIsolationVirtualServiceRoutes" -}} +{{- $contract := .Files.Get "files/contracts/api-workload-isolation-routes.yaml" | fromYaml -}} +{{- $ingest := include "braintrust.apiPoolConfig" (dict "root" . "overrides" .Values.api.workloadIsolation.ingest) | fromYaml -}} +{{- $background := include "braintrust.apiPoolConfig" (dict "root" . "overrides" .Values.api.workloadIsolation.background) | fromYaml -}} +{{- $ingestDestination := dict "host" ($ingest.service.name | default $ingest.name) "port" (dict "number" $ingest.service.port) -}} +{{- $backgroundDestination := dict "host" ($background.service.name | default $background.name) "port" (dict "number" $background.service.port) -}} +{{- $routes := list -}} +{{- range $route := $contract.pools.ingest.routes -}} +{{- $match := dict "uri" (dict $route.pathType $route.path) -}} +{{- if $route.method -}} +{{- $_ := set $match "method" (dict "exact" $route.method) -}} +{{- end -}} +{{- $routes = append $routes (dict "match" (list $match) "route" (list (dict "destination" $ingestDestination))) -}} +{{- end -}} +{{- range $route := $contract.pools.background.routes -}} +{{- $match := dict "uri" (dict $route.pathType $route.path) -}} +{{- if $route.method -}} +{{- $_ := set $match "method" (dict "exact" $route.method) -}} +{{- end -}} +{{- $routes = append $routes (dict "match" (list $match) "route" (list (dict "destination" $backgroundDestination))) -}} +{{- end -}} +{{- toYaml $routes -}} +{{- end -}} + +{{/* +Internal cluster URL Brainstore uses for function/scoring traffic. The +background pool is used only after workload isolation has been activated for +Brainstore, allowing existing deployments to stage a ready background pool. +*/}} +{{- define "braintrust.apiAiProxyInternalUrl" -}} +{{- if and .Values.api.workloadIsolation.enabled .Values.api.workloadIsolation.brainstoreAiProxyToBackground -}} +{{- $background := include "braintrust.apiPoolConfig" (dict "root" . "overrides" .Values.api.workloadIsolation.background) | fromYaml -}} +http://{{ $background.service.name | default $background.name }}:{{ $background.service.port }} +{{- else -}} +http://{{ .Values.api.service.name | default .Values.api.name }}:{{ .Values.api.service.port }} +{{- end -}} +{{- end -}} + {{/* Internal cluster URL for the AI Gateway service. */}} diff --git a/braintrust/templates/api-deployment.yaml b/braintrust/templates/api-deployment.yaml index 43d6c12..3575630 100644 --- a/braintrust/templates/api-deployment.yaml +++ b/braintrust/templates/api-deployment.yaml @@ -1,211 +1,8 @@ -{{- $customCA := .Values.api.customCA -}} -{{- $customCAMountPath := "" -}} -{{- $customCAFilename := "" -}} -{{- $customCASecretName := "" -}} -{{- $customCASecretKey := "" -}} -{{- if $customCA.enabled -}} -{{- $customCAMountPath = required "api.customCA.mountPath is required when api.customCA.enabled is true" $customCA.mountPath -}} -{{- $customCAFilename = required "api.customCA.filename is required when api.customCA.enabled is true" $customCA.filename -}} -{{- $customCASecretName = required "api.customCA.secretName is required when api.customCA.enabled is true" $customCA.secretName -}} -{{- $customCASecretKey = required "api.customCA.secretKey is required when api.customCA.enabled is true" $customCA.secretKey -}} -{{- end -}} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Values.api.name }} - namespace: {{ include "braintrust.namespace" . }} - {{- with (merge (deepCopy .Values.api.labels) .Values.global.labels) }} - labels: - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .Values.api.annotations.deployment }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - replicas: {{ .Values.api.replicas }} - strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 100% - maxUnavailable: 0 - selector: - matchLabels: - app: {{ .Values.api.name }} - template: - metadata: - labels: - app: {{ .Values.api.name }} - {{- if eq .Values.cloud "azure" }} - azure.workload.identity/use: "true" - {{- end }} - {{- if and (eq .Values.cloud "google") .Values.api.enableGcsAuth }} - gke-workload-identity/use: "true" - {{- end }} - {{- with (merge (deepCopy .Values.api.podLabels) .Values.api.labels .Values.global.labels) }} - {{- toYaml . | nindent 8 }} - {{- end }} - annotations: - checksum/config: {{ include (print $.Template.BasePath "/api-configmap.yaml") . | sha256sum }} - {{- if and (eq .Values.cloud "google") .Values.api.enableGcsAuth }} - iam.gke.io/gcp-service-account: {{ required "api.serviceAccount.googleServiceAccount is required when api.enableGcsAuth is true" .Values.api.serviceAccount.googleServiceAccount }} - {{- end }} - {{- with .Values.api.annotations.pod }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - serviceAccountName: {{ .Values.api.serviceAccount.name }} - {{- with .Values.api.podSecurityContext }} - securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.api.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.api.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.api.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - containers: - - name: api - image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}" - imagePullPolicy: {{ .Values.api.image.pullPolicy }} - {{- with .Values.api.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - ports: - - containerPort: {{ .Values.api.service.port }} - resources: - {{- toYaml .Values.api.resources | nindent 12 }} - {{- with .Values.api.livenessProbe }} - livenessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.api.readinessProbe }} - readinessProbe: - {{- toYaml . | nindent 12 }} - {{- end }} - envFrom: - - configMapRef: - name: {{ .Values.api.name }} - env: - - name: PG_URL - valueFrom: - secretKeyRef: - name: braintrust-secrets - key: PG_URL - - name: REDIS_URL - valueFrom: - secretKeyRef: - name: braintrust-secrets - key: REDIS_URL - - name: FUNCTION_SECRET_KEY - valueFrom: - secretKeyRef: - name: braintrust-secrets - key: FUNCTION_SECRET_KEY - - name: BRAINSTORE_LICENSE_KEY - valueFrom: - secretKeyRef: - name: braintrust-secrets - key: BRAINSTORE_LICENSE_KEY - {{- if eq .Values.cloud "azure" }} - - name: AZURE_STORAGE_CONNECTION_STRING - valueFrom: - secretKeyRef: - name: braintrust-secrets - key: AZURE_STORAGE_CONNECTION_STRING - {{- end }} - {{- if and (eq .Values.cloud "google") (not .Values.api.enableGcsAuth) }} - - name: AWS_ACCESS_KEY_ID - valueFrom: - secretKeyRef: - name: braintrust-secrets - key: GCS_ACCESS_KEY_ID - - name: AWS_SECRET_ACCESS_KEY - valueFrom: - secretKeyRef: - name: braintrust-secrets - key: GCS_SECRET_ACCESS_KEY - {{- end }} - - name: TS_API_HEALTHSERVER_HOST - value: {{ .Values.api.healthServer.host | quote }} - - name: TS_API_HEALTHSERVER_PORT - value: {{ .Values.api.healthServer.port | quote }} - {{- if $customCA.enabled }} - {{- $customCAPath := printf "%s/%s" $customCAMountPath $customCAFilename }} - - name: NODE_EXTRA_CA_CERTS - value: {{ $customCAPath | quote }} - - name: REQUESTS_CA_BUNDLE - value: {{ $customCAPath | quote }} - - name: SSL_CERT_FILE - value: {{ $customCAPath | quote }} - - name: CURL_CA_BUNDLE - value: {{ $customCAPath | quote }} - - name: AWS_CA_BUNDLE - value: {{ $customCAPath | quote }} - - name: PIP_CERT - value: {{ $customCAPath | quote }} - {{- end }} - {{- if .Values.api.extraEnvVars }} - {{- toYaml .Values.api.extraEnvVars | nindent 12 }} - {{- end }} - {{- if or .Values.api.tmpVolume.enabled (and (eq .Values.cloud "azure") .Values.azure.enableAzureKeyVaultDriver) $customCA.enabled }} - volumeMounts: - {{- if .Values.api.tmpVolume.enabled }} - - name: tmp-volume - mountPath: /tmp - {{- end }} - {{- if and (eq .Values.cloud "azure") .Values.azure.enableAzureKeyVaultDriver }} - - name: secrets-store-inline - mountPath: "/mnt/secrets-store" - readOnly: true - {{- end }} - {{- if $customCA.enabled }} - - name: custom-ca-bundle - mountPath: {{ $customCAMountPath | quote }} - readOnly: true - {{- end }} - {{- end }} - {{- with .Values.api.extraContainers }} - {{- toYaml . | nindent 8 }} - {{- end }} - volumes: - {{- if or .Values.api.tmpVolume.enabled (and (eq .Values.cloud "azure") .Values.azure.enableAzureKeyVaultDriver) $customCA.enabled .Values.api.extraVolumes }} - {{- if .Values.api.tmpVolume.enabled }} - - name: tmp-volume - emptyDir: - {{- if .Values.api.tmpVolume.sizeLimit }} - sizeLimit: {{ .Values.api.tmpVolume.sizeLimit | quote }} - {{- else }} - {} - {{- end }} - {{- end }} - {{- if and (eq .Values.cloud "azure") .Values.azure.enableAzureKeyVaultDriver }} - - name: secrets-store-inline - csi: - driver: secrets-store.csi.k8s.io - readOnly: true - volumeAttributes: - secretProviderClass: {{ .Values.azure.keyVaultName }} - {{- end }} - {{- if $customCA.enabled }} - - name: custom-ca-bundle - secret: - secretName: {{ $customCASecretName | quote }} - items: - - key: {{ $customCASecretKey | quote }} - path: {{ $customCAFilename | quote }} - {{- end }} - {{- with .Values.api.extraVolumes }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- else }} - [] - {{- end }} +{{- $root := . -}} +{{- $pools := include "braintrust.apiPools" . | fromYamlArray -}} +{{- range $index, $pool := $pools }} +{{- if gt $index 0 }} +--- +{{- end }} +{{ include "braintrust.apiDeployment" (dict "root" $root "api" $pool.config "role" $pool.role) }} +{{- end }} diff --git a/braintrust/templates/api-pdb.yaml b/braintrust/templates/api-pdb.yaml new file mode 100644 index 0000000..3fe35ff --- /dev/null +++ b/braintrust/templates/api-pdb.yaml @@ -0,0 +1,27 @@ +{{- $root := . -}} +{{- $pools := include "braintrust.apiPools" . | fromYamlArray -}} +{{- $rendered := 0 -}} +{{- range $pool := $pools -}} +{{- $api := $pool.config -}} +{{- if $api.podDisruptionBudget.enabled }} +{{- if gt $rendered 0 }} +--- +{{- end }} +{{ $resourceLabels := mergeOverwrite (deepCopy $root.Values.global.labels) (deepCopy $api.labels) (dict "braintrust.dev/api-pool" $pool.role) -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ $api.name }} + namespace: {{ include "braintrust.namespace" $root }} + {{- with $resourceLabels }} + labels: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + minAvailable: {{ $api.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + app: {{ $api.name }} +{{- $rendered = add1 $rendered -}} +{{- end }} +{{- end }} diff --git a/braintrust/templates/api-service.yaml b/braintrust/templates/api-service.yaml index 0ff776c..f5e6dbf 100644 --- a/braintrust/templates/api-service.yaml +++ b/braintrust/templates/api-service.yaml @@ -1,22 +1,8 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ .Values.api.service.name | default .Values.api.name }} - namespace: {{ include "braintrust.namespace" . }} - {{- with (merge (deepCopy .Values.api.labels) .Values.global.labels) }} - labels: - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .Values.api.annotations.service }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - selector: - app: {{ .Values.api.name }} - ports: - - name: {{ .Values.api.service.portName }} - protocol: TCP - port: {{ .Values.api.service.port }} - targetPort: {{ .Values.api.service.port }} - type: {{ .Values.api.service.type }} +{{- $root := . -}} +{{- $pools := include "braintrust.apiPools" . | fromYamlArray -}} +{{- range $index, $pool := $pools }} +{{- if gt $index 0 }} +--- +{{- end }} +{{ include "braintrust.apiService" (dict "root" $root "api" $pool.config "role" $pool.role) }} +{{- end }} diff --git a/braintrust/templates/brainstore-fastreader-deployment.yaml b/braintrust/templates/brainstore-fastreader-deployment.yaml index ea43276..2270917 100644 --- a/braintrust/templates/brainstore-fastreader-deployment.yaml +++ b/braintrust/templates/brainstore-fastreader-deployment.yaml @@ -119,7 +119,7 @@ spec: name: braintrust-secrets key: REDIS_URL - name: BRAINSTORE_AI_PROXY_URL - value: "http://{{ .Values.api.service.name | default .Values.api.name }}:{{ .Values.api.service.port }}" + value: {{ include "braintrust.apiAiProxyInternalUrl" . | quote }} {{- if eq .Values.brainstore.locksBackend "redis" }} - name: BRAINSTORE_LOCKS_URI valueFrom: diff --git a/braintrust/templates/brainstore-reader-deployment.yaml b/braintrust/templates/brainstore-reader-deployment.yaml index d3aa0b5..bab062a 100644 --- a/braintrust/templates/brainstore-reader-deployment.yaml +++ b/braintrust/templates/brainstore-reader-deployment.yaml @@ -119,7 +119,7 @@ spec: name: braintrust-secrets key: REDIS_URL - name: BRAINSTORE_AI_PROXY_URL - value: "http://{{ .Values.api.service.name | default .Values.api.name }}:{{ .Values.api.service.port }}" + value: {{ include "braintrust.apiAiProxyInternalUrl" . | quote }} {{- if eq .Values.brainstore.locksBackend "redis" }} - name: BRAINSTORE_LOCKS_URI valueFrom: diff --git a/braintrust/templates/brainstore-writer-deployment.yaml b/braintrust/templates/brainstore-writer-deployment.yaml index 0a2dab2..3ffc61b 100644 --- a/braintrust/templates/brainstore-writer-deployment.yaml +++ b/braintrust/templates/brainstore-writer-deployment.yaml @@ -119,7 +119,7 @@ spec: name: braintrust-secrets key: REDIS_URL - name: BRAINSTORE_AI_PROXY_URL - value: "http://{{ .Values.api.service.name | default .Values.api.name }}:{{ .Values.api.service.port }}" + value: {{ include "braintrust.apiAiProxyInternalUrl" . | quote }} {{- if eq .Values.brainstore.locksBackend "redis" }} - name: BRAINSTORE_LOCKS_URI valueFrom: diff --git a/braintrust/templates/virtualservice.yaml b/braintrust/templates/virtualservice.yaml index 9b05ee4..5b4dd8d 100644 --- a/braintrust/templates/virtualservice.yaml +++ b/braintrust/templates/virtualservice.yaml @@ -1,3 +1,10 @@ +{{- $virtualServiceWorkloadIsolation := .Values.virtualService.workloadIsolation | default dict -}} +{{- if and $virtualServiceWorkloadIsolation.enabled (not .Values.virtualService.enabled) -}} +{{- fail "virtualService.workloadIsolation.enabled requires virtualService.enabled." -}} +{{- end -}} +{{- if and $virtualServiceWorkloadIsolation.enabled (not .Values.api.workloadIsolation.enabled) -}} +{{- fail "virtualService.workloadIsolation.enabled requires api.workloadIsolation.enabled." -}} +{{- end -}} {{- if .Values.virtualService.enabled }} apiVersion: networking.istio.io/v1 kind: VirtualService @@ -22,7 +29,10 @@ spec: {{- toYaml . | nindent 4 }} {{- end }} http: + {{- if $virtualServiceWorkloadIsolation.enabled }} + {{- include "braintrust.apiWorkloadIsolationVirtualServiceRoutes" . | nindent 4 }} + {{- end }} {{- range .Values.virtualService.http }} - - {{- toYaml . | nindent 6 }} + - {{ toYaml . | nindent 6 | trim }} {{- end }} {{- end }} diff --git a/braintrust/tests/api-workload-isolation_test.yaml b/braintrust/tests/api-workload-isolation_test.yaml new file mode 100644 index 0000000..6464553 --- /dev/null +++ b/braintrust/tests/api-workload-isolation_test.yaml @@ -0,0 +1,224 @@ +suite: test API workload isolation +templates: + - api-deployment.yaml + - api-configmap.yaml + - api-service.yaml + - api-pdb.yaml + - brainstore-fastreader-deployment.yaml + - brainstore-fastreader-configmap.yaml +tests: + - it: should preserve one default deployment when isolation is disabled + template: api-deployment.yaml + values: + - __fixtures__/base-values.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: braintrust-api + - equal: + path: spec.replicas + value: 1 + - isNull: + path: metadata.labels["braintrust.dev/api-pool"] + - isNull: + path: spec.template.metadata.labels["braintrust.dev/api-pool"] + + - it: should not label the default Service when isolation is disabled + template: api-service.yaml + values: + - __fixtures__/base-values.yaml + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: braintrust-api + - isNull: + path: metadata.labels["braintrust.dev/api-pool"] + + - it: should render the ingest deployment with its own fixed capacity and topology budget + template: api-deployment.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.workloadIsolation.ingest.replicas: 3 + documentIndex: 1 + asserts: + - equal: + path: metadata.name + value: braintrust-api-ingest + - equal: + path: spec.replicas + value: 3 + - equal: + path: spec.template.metadata.labels["braintrust.dev/api-pool"] + value: ingest + - equal: + path: spec.template.spec.topologySpreadConstraints[0].labelSelector.matchLabels.app + value: braintrust-api-ingest + + - it: should merge shared and background-specific API configuration + template: api-deployment.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.workloadIsolation.background.replicas: 5 + api.extraEnvVars: + - name: SHARED_SETTING + value: shared + api.workloadIsolation.background.extraEnvVars: + - name: POOL_SETTING + value: background + api.workloadIsolation.background.resources: + requests: + cpu: "3" + memory: 6Gi + limits: + cpu: "3" + memory: 6Gi + documentIndex: 2 + asserts: + - equal: + path: metadata.name + value: braintrust-api-background + - equal: + path: spec.replicas + value: 5 + - equal: + path: spec.template.spec.containers[0].resources.requests.cpu + value: "3" + - contains: + path: spec.template.spec.containers[0].env + content: + name: SHARED_SETTING + value: shared + - contains: + path: spec.template.spec.containers[0].env + content: + name: POOL_SETTING + value: background + + - it: should render an isolated ingest Service with inherited annotations + template: api-service.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.annotations.service: + cloud.google.com/neg: '{"ingress":true}' + documentIndex: 1 + asserts: + - equal: + path: metadata.name + value: braintrust-api-ingest + - equal: + path: metadata.annotations["cloud.google.com/neg"] + value: '{"ingress":true}' + - equal: + path: spec.selector.app + value: braintrust-api-ingest + + - it: should render a dedicated disruption budget for the background pool + template: api-pdb.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.workloadIsolation.background.podDisruptionBudget.minAvailable: 2 + documentIndex: 1 + asserts: + - isKind: + of: PodDisruptionBudget + - equal: + path: metadata.name + value: braintrust-api-background + - equal: + path: spec.minAvailable + value: 2 + - equal: + path: spec.selector.matchLabels.app + value: braintrust-api-background + + - it: should route fast-reader AI proxy traffic to the background pool + template: brainstore-fastreader-deployment.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BRAINSTORE_AI_PROXY_URL + value: "http://braintrust-api-background:8000" + + - it: should retain the default AI proxy target while the background pool is staged + template: brainstore-fastreader-deployment.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.workloadIsolation.brainstoreAiProxyToBackground: false + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BRAINSTORE_AI_PROXY_URL + value: "http://braintrust-api:8000" + + - it: should isolate Deployment labels between API pools + template: api-deployment.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.workloadIsolation.ingest.labels.ingest-only: ingest + api.workloadIsolation.background.labels.background-only: background + documentIndex: 1 + asserts: + - equal: + path: metadata.labels["ingest-only"] + value: ingest + - isNull: + path: metadata.labels["background-only"] + - equal: + path: spec.template.metadata.labels["ingest-only"] + value: ingest + - isNull: + path: spec.template.metadata.labels["background-only"] + + - it: should isolate Service labels between API pools + template: api-service.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.workloadIsolation.ingest.labels.ingest-only: ingest + api.workloadIsolation.background.labels.background-only: background + documentIndex: 2 + asserts: + - equal: + path: metadata.labels["background-only"] + value: background + - isNull: + path: metadata.labels["ingest-only"] + + - it: should isolate disruption-budget labels between API pools + template: api-pdb.yaml + values: + - __fixtures__/base-values.yaml + set: + api.workloadIsolation.enabled: true + api.workloadIsolation.ingest.labels.ingest-only: ingest + api.workloadIsolation.background.labels.background-only: background + documentIndex: 0 + asserts: + - equal: + path: metadata.labels["ingest-only"] + value: ingest + - isNull: + path: metadata.labels["background-only"] diff --git a/braintrust/tests/brainstore-reader_test.yaml b/braintrust/tests/brainstore-reader_test.yaml index b885550..dd5e99e 100644 --- a/braintrust/tests/brainstore-reader_test.yaml +++ b/braintrust/tests/brainstore-reader_test.yaml @@ -325,6 +325,21 @@ tests: name: BRAINSTORE_AI_PROXY_URL value: "http://braintrust-api:8000" + - it: should route AI proxy traffic to the background API pool when isolation is enabled + template: brainstore-reader-deployment.yaml + values: + - __fixtures__/base-values.yaml + release: + namespace: "braintrust" + set: + api.workloadIsolation.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BRAINSTORE_AI_PROXY_URL + value: "http://braintrust-api-background:8000" + - it: should include extraContainers when provided (e.g. OTEL collector sidecar) template: brainstore-reader-deployment.yaml values: diff --git a/braintrust/tests/brainstore-writer_test.yaml b/braintrust/tests/brainstore-writer_test.yaml index c656c32..a2564b5 100644 --- a/braintrust/tests/brainstore-writer_test.yaml +++ b/braintrust/tests/brainstore-writer_test.yaml @@ -323,3 +323,18 @@ tests: content: name: BRAINSTORE_AI_PROXY_URL value: "http://braintrust-api:8000" + + - it: should route AI proxy traffic to the background API pool when isolation is enabled + template: brainstore-writer-deployment.yaml + values: + - __fixtures__/base-values.yaml + release: + namespace: "braintrust" + set: + api.workloadIsolation.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BRAINSTORE_AI_PROXY_URL + value: "http://braintrust-api-background:8000" diff --git a/braintrust/tests/virtualservice_test.yaml b/braintrust/tests/virtualservice_test.yaml index cb38a25..99913b2 100644 --- a/braintrust/tests/virtualservice_test.yaml +++ b/braintrust/tests/virtualservice_test.yaml @@ -250,3 +250,80 @@ tests: - equal: path: spec.http[0].route[0].weight value: 100 + + - it: should route the workload-isolation contract ahead of custom fallback rules + values: + - __fixtures__/base-values.yaml + set: + virtualService.enabled: true + virtualService.workloadIsolation.enabled: true + virtualService.http: + - match: + - uri: + prefix: "/" + route: + - destination: + host: "braintrust-api" + port: + number: 8000 + api.workloadIsolation.enabled: true + api.workloadIsolation.ingest.service.name: "custom-ingest" + api.workloadIsolation.background.service.name: "custom-background" + asserts: + - equal: + path: spec.http[0].match[0].uri.exact + value: /logs3 + - equal: + path: spec.http[0].route[0].destination.host + value: custom-ingest + - equal: + path: spec.http[0].match[0].method.exact + value: POST + - equal: + path: spec.http[4].match[0].uri.exact + value: /v1/eval + - equal: + path: spec.http[4].route[0].destination.host + value: custom-background + - equal: + path: spec.http[5].match[0].uri.prefix + value: /v1/eval/ + - equal: + path: spec.http[5].match[0].method.exact + value: POST + - equal: + path: spec.http[13].match[0].uri.exact + value: /v1/proxy/responses + - equal: + path: spec.http[13].route[0].destination.host + value: custom-background + - isNull: + path: spec.http[13].match[0].method + - equal: + path: spec.http[14].match[0].uri.prefix + value: / + - equal: + path: spec.http[14].route[0].destination.host + value: braintrust-api + + - it: should reject workload-isolation routes without the API pools + values: + - __fixtures__/base-values.yaml + set: + virtualService.enabled: true + virtualService.workloadIsolation.enabled: true + api.workloadIsolation.enabled: false + asserts: + - failedTemplate: + errorMessage: "virtualService.workloadIsolation.enabled requires api.workloadIsolation.enabled." + + - it: should reject workload-isolation routes when the VirtualService is disabled + values: + - __fixtures__/base-values.yaml + set: + virtualService.enabled: false + virtualService.workloadIsolation.enabled: true + api.workloadIsolation.enabled: true + asserts: + - failedTemplate: + errorMessage: "virtualService.workloadIsolation.enabled requires virtualService.enabled." diff --git a/braintrust/values.yaml b/braintrust/values.yaml index 6dd8d34..c0dcd7c 100644 --- a/braintrust/values.yaml +++ b/braintrust/values.yaml @@ -111,6 +111,35 @@ api: pod: {} serviceaccount: {} replicas: 4 + # Optional fixed-capacity workload isolation. This creates dedicated API + # Deployments and Services for ingestion and background work while retaining + # api.name as the default pool for all remaining traffic. Public ingress must + # route the documented paths to these Services when this is enabled. + workloadIsolation: + enabled: false + # Keep this false while staging pools for an existing deployment. Set it + # true only after the background pool is healthy and its routes are ready. + brainstoreAiProxyToBackground: true + ingest: + name: "braintrust-api-ingest" + replicas: 3 + service: + name: "braintrust-api-ingest" + podDisruptionBudget: + enabled: true + minAvailable: 1 + topologySpread: + enabled: true + background: + name: "braintrust-api-background" + replicas: 3 + service: + name: "braintrust-api-background" + podDisruptionBudget: + enabled: true + minAvailable: 1 + topologySpread: + enabled: true image: repository: public.ecr.aws/braintrust/standalone-api tag: v2.9.0 @@ -136,6 +165,19 @@ api: limits: cpu: "4" memory: "8Gi" + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 100% + maxUnavailable: 0 + podDisruptionBudget: + enabled: false + minAvailable: 1 + topologySpread: + enabled: false + maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway tmpVolume: enabled: false sizeLimit: "" @@ -503,9 +545,16 @@ virtualService: # List of hosts/domains hosts: - "braintrust.example.com" + # Optional integration with api.workloadIsolation. When enabled, the chart + # emits the product-owned ingest/background route contract before the routes + # below. This must be enabled only after the isolated pools are healthy. + workloadIsolation: + enabled: false # HTTP routing rules - # The destination host will use api.service.name (or api.name if service.name is empty) - # The destination port will use api.service.port (default: 8000) + # These routes are retained after workload-isolation routes as fallback or + # custom behavior for non-classified paths. The destination host will use + # api.service.name (or api.name if service.name is empty); the destination + # port defaults to 8000. http: - match: - uri: