From 065febb8d0b41b9d8fa4524c12630d4ae8453e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Fri, 5 Jun 2026 15:03:14 +0100 Subject: [PATCH 1/8] Replace Engine with Worker --- chart/templates/_helpers.tpl | 4 +- chart/templates/deployment-app.yaml | 2 + ...ent-engine.yaml => deployment-worker.yaml} | 41 +++++++++---------- chart/templates/external-secret.yaml | 4 +- .../{hpa-engine.yaml => hpa-worker.yaml} | 20 ++++----- chart/templates/networkpolicy.yaml | 18 ++++---- .../{pdb-engine.yaml => pdb-worker.yaml} | 10 ++--- chart/templates/secret-env.yaml | 2 +- ...ervice-engine.yaml => service-worker.yaml} | 10 ++--- ...engine.yaml => serviceaccount-worker.yaml} | 8 ++-- chart/templates/servicemonitor.yaml | 10 ++--- chart/values.ci.yaml | 2 +- chart/values.production.yaml | 2 +- chart/values.schema.json | 4 +- chart/values.yaml | 12 +++--- 15 files changed, 75 insertions(+), 74 deletions(-) rename chart/templates/{deployment-engine.yaml => deployment-worker.yaml} (73%) rename chart/templates/{hpa-engine.yaml => hpa-worker.yaml} (54%) rename chart/templates/{pdb-engine.yaml => pdb-worker.yaml} (60%) rename chart/templates/{service-engine.yaml => service-worker.yaml} (65%) rename chart/templates/{serviceaccount-engine.yaml => serviceaccount-worker.yaml} (68%) diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index abf6c4b..1c62de6 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -144,8 +144,8 @@ Service URLs {{- printf "http://%s" .Values.app.name -}} {{- end }} -{{- define "openops.engineServiceUrl" -}} -{{- printf "http://%s:3005" .Values.engine.name -}} +{{- define "openops.workerServiceUrl" -}} +{{- printf "http://%s:3000" .Values.worker.name -}} {{- end }} {{- define "openops.tablesServiceUrl" -}} diff --git a/chart/templates/deployment-app.yaml b/chart/templates/deployment-app.yaml index b19bb48..a6eb81b 100644 --- a/chart/templates/deployment-app.yaml +++ b/chart/templates/deployment-app.yaml @@ -74,6 +74,8 @@ spec: value: "--no-node-snapshot" - name: OPS_COMPONENT value: app + - name: OPS_CONTAINER_TYPE + value: APP {{ include "openops.renderEnv" (dict "root" . "env" .Values.openopsEnv "skipDuplicateSecrets" true) | nindent 12 }} {{ include "openops.renderEnv" (dict "root" . "env" .Values.openopsEnvSecrets) | nindent 12 }} ports: diff --git a/chart/templates/deployment-engine.yaml b/chart/templates/deployment-worker.yaml similarity index 73% rename from chart/templates/deployment-engine.yaml rename to chart/templates/deployment-worker.yaml index 6b567c6..a4c46e9 100644 --- a/chart/templates/deployment-engine.yaml +++ b/chart/templates/deployment-worker.yaml @@ -1,9 +1,9 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ .Values.engine.name }} + name: {{ .Values.worker.name }} labels: - {{- include "openops.componentLabels" (dict "root" . "component" "engine") | nindent 4 }} + {{- include "openops.componentLabels" (dict "root" . "component" "worker") | nindent 4 }} {{- with .Values.global.commonLabels }} {{- toYaml . | nindent 4 }} {{- end }} @@ -12,22 +12,22 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if not .Values.engine.autoscaling.enabled }} - replicas: {{ .Values.engine.replicas }} + {{- if not .Values.worker.autoscaling.enabled }} + replicas: {{ .Values.worker.replicas }} {{- end }} {{- include "openops.deploymentStrategy" . | nindent 2 }} selector: matchLabels: - {{- include "openops.componentSelectorLabels" (dict "root" . "component" "engine") | nindent 6 }} + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "worker") | nindent 6 }} template: metadata: labels: - {{- include "openops.componentSelectorLabels" (dict "root" . "component" "engine") | nindent 8 }} + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "worker") | nindent 8 }} {{- with .Values.global.commonLabels }} {{- toYaml . | nindent 8 }} {{- end }} annotations: - {{- include "openops.prometheusAnnotations" (dict "root" . "component" "engine") | nindent 8 }} + {{- include "openops.prometheusAnnotations" (dict "root" . "component" "worker") | nindent 8 }} {{- with include "openops.secretChecksum" . }} checksum/secret-env: {{ . }} {{- end }} @@ -35,34 +35,33 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} spec: - serviceAccountName: {{ include "openops.serviceAccountName" (dict "root" . "component" "engine") }} + serviceAccountName: {{ include "openops.serviceAccountName" (dict "root" . "component" "worker") }} {{- include "openops.imagePullSecrets" . | nindent 6 }} - {{- include "openops.podSecurityContext" (dict "root" . "component" "engine") | nindent 6 }} + {{- include "openops.podSecurityContext" (dict "root" . "component" "worker") | nindent 6 }} {{- include "openops.priorityClassName" . | nindent 6 }} {{- include "openops.terminationGracePeriodSeconds" . | nindent 6 }} - {{- include "openops.topologySpreadConstraints" (dict "root" . "component" "engine") | nindent 6 }} - {{- include "openops.affinity" (dict "root" . "component" "engine") | nindent 6 }} + {{- include "openops.topologySpreadConstraints" (dict "root" . "component" "worker") | nindent 6 }} + {{- include "openops.affinity" (dict "root" . "component" "worker") | nindent 6 }} {{- include "openops.nodeSelector" . | nindent 6 }} {{- include "openops.tolerations" . | nindent 6 }} - {{- include "openops.initContainers" (dict "root" . "component" "engine") | nindent 6 }} + {{- include "openops.initContainers" (dict "root" . "component" "worker") | nindent 6 }} containers: - - name: {{ .Values.engine.name }} - image: "{{ include "openops.imageRepository" (dict "root" . "component" "engine") }}/{{ .Values.engine.image }}:{{ .Values.global.version }}" + - name: {{ .Values.worker.name }} + image: "{{ include "openops.imageRepository" (dict "root" . "component" "worker") }}/{{ .Values.worker.image }}:{{ .Values.global.version }}" imagePullPolicy: {{ .Values.image.pullPolicy }} {{- include "openops.containerSecurityContext" (dict "root" .) | nindent 10 }} - command: [ "/bin/sh", "-c", "cp -r /var/tmp-base/. /tmp/ && node main.js" ] env: - - name: NODE_OPTIONS - value: "--no-node-snapshot" - name: OPS_COMPONENT - value: engine + value: worker + - name: OPS_CONTAINER_TYPE + value: WORKER {{ include "openops.renderEnv" (dict "root" . "env" .Values.openopsEnv "skipDuplicateSecrets" true) | nindent 12 }} {{ include "openops.renderEnv" (dict "root" . "env" .Values.openopsEnvSecrets) | nindent 12 }} -{{ include "openops.renderEnv" (dict "root" . "env" .Values.engine.env) | nindent 12 }} +{{ include "openops.renderEnv" (dict "root" . "env" .Values.worker.env) | nindent 12 }} ports: - - containerPort: 3005 + - containerPort: 3000 name: http -{{- with .Values.engine.resources }} +{{- with .Values.worker.resources }} resources: {{ toYaml . | nindent 12 }} {{- end }} diff --git a/chart/templates/external-secret.yaml b/chart/templates/external-secret.yaml index 6d74d71..2030451 100644 --- a/chart/templates/external-secret.yaml +++ b/chart/templates/external-secret.yaml @@ -71,8 +71,8 @@ spec: {{- range $k, $v := .Values.tables.env }}{{ if not (hasKey $allEnv $k) }}{{ $_ := set $allEnv $k ($v | toString) }}{{ end }}{{ end }} {{- range $k, $v := .Values.analytics.env }}{{ if not (hasKey $allEnv $k) }}{{ $_ := set $allEnv $k ($v | toString) }}{{ end }}{{ end }} {{- range $k, $v := .Values.postgres.env }}{{ if not (hasKey $allEnv $k) }}{{ $_ := set $allEnv $k ($v | toString) }}{{ end }}{{ end }} -{{- if .Values.engine }}{{- if .Values.engine.env }} -{{- range $k, $v := .Values.engine.env }}{{ if not (hasKey $allEnv $k) }}{{ $_ := set $allEnv $k ($v | toString) }}{{ end }}{{ end }} +{{- if .Values.worker }}{{- if .Values.worker.env }} +{{- range $k, $v := .Values.worker.env }}{{ if not (hasKey $allEnv $k) }}{{ $_ := set $allEnv $k ($v | toString) }}{{ end }}{{ end }} {{- end }}{{- end }} {{- include "openops.collectSecretEntries" (dict "root" $ "env" $allEnv "secretName" .Values.externalSecrets.secretName) }} {{- end }} diff --git a/chart/templates/hpa-engine.yaml b/chart/templates/hpa-worker.yaml similarity index 54% rename from chart/templates/hpa-engine.yaml rename to chart/templates/hpa-worker.yaml index 608f9fc..1021e94 100644 --- a/chart/templates/hpa-engine.yaml +++ b/chart/templates/hpa-worker.yaml @@ -1,10 +1,10 @@ -{{- if .Values.engine.autoscaling.enabled }} +{{- if .Values.worker.autoscaling.enabled }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: - name: {{ .Values.engine.name }}-hpa + name: {{ .Values.worker.name }}-hpa labels: - {{- include "openops.componentLabels" (dict "root" . "component" "engine") | nindent 4 }} + {{- include "openops.componentLabels" (dict "root" . "component" "worker") | nindent 4 }} {{- with .Values.global.commonLabels }} {{- toYaml . | nindent 4 }} {{- end }} @@ -12,24 +12,24 @@ spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment - name: {{ .Values.engine.name }} - minReplicas: {{ .Values.engine.autoscaling.minReplicas }} - maxReplicas: {{ .Values.engine.autoscaling.maxReplicas }} + name: {{ .Values.worker.name }} + minReplicas: {{ .Values.worker.autoscaling.minReplicas }} + maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }} metrics: - {{- if .Values.engine.autoscaling.targetCPUUtilizationPercentage }} + {{- if .Values.worker.autoscaling.targetCPUUtilizationPercentage }} - type: Resource resource: name: cpu target: type: Utilization - averageUtilization: {{ .Values.engine.autoscaling.targetCPUUtilizationPercentage }} + averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }} {{- end }} - {{- if .Values.engine.autoscaling.targetMemoryUtilizationPercentage }} + {{- if .Values.worker.autoscaling.targetMemoryUtilizationPercentage }} - type: Resource resource: name: memory target: type: Utilization - averageUtilization: {{ .Values.engine.autoscaling.targetMemoryUtilizationPercentage }} + averageUtilization: {{ .Values.worker.autoscaling.targetMemoryUtilizationPercentage }} {{- end }} {{- end }} diff --git a/chart/templates/networkpolicy.yaml b/chart/templates/networkpolicy.yaml index c9dc2b1..6a4eccf 100644 --- a/chart/templates/networkpolicy.yaml +++ b/chart/templates/networkpolicy.yaml @@ -40,10 +40,10 @@ spec: - to: - podSelector: matchLabels: - app.kubernetes.io/component: engine + app.kubernetes.io/component: worker ports: - protocol: TCP - port: 3005 + port: 3000 - to: - podSelector: matchLabels: @@ -71,17 +71,17 @@ spec: - protocol: TCP port: 443 --- -# Network policy for engine +# Network policy for worker apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: - name: {{ .Values.engine.name }}-netpol + name: {{ .Values.worker.name }}-netpol labels: - {{- include "openops.componentLabels" (dict "root" . "component" "engine") | nindent 4 }} + {{- include "openops.componentLabels" (dict "root" . "component" "worker") | nindent 4 }} spec: podSelector: matchLabels: - {{- include "openops.componentSelectorLabels" (dict "root" . "component" "engine") | nindent 6 }} + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "worker") | nindent 6 }} policyTypes: - Ingress - Egress @@ -92,7 +92,7 @@ spec: app.kubernetes.io/component: app ports: - protocol: TCP - port: 3005 + port: 3000 egress: - to: - podSelector: @@ -306,7 +306,7 @@ spec: - from: - podSelector: matchLabels: - app.kubernetes.io/component: engine + app.kubernetes.io/component: worker ports: - protocol: TCP port: 5432 @@ -358,7 +358,7 @@ spec: - from: - podSelector: matchLabels: - app.kubernetes.io/component: engine + app.kubernetes.io/component: worker ports: - protocol: TCP port: 6379 diff --git a/chart/templates/pdb-engine.yaml b/chart/templates/pdb-worker.yaml similarity index 60% rename from chart/templates/pdb-engine.yaml rename to chart/templates/pdb-worker.yaml index b0e670e..17a54ac 100644 --- a/chart/templates/pdb-engine.yaml +++ b/chart/templates/pdb-worker.yaml @@ -1,16 +1,16 @@ -{{- if .Values.engine.podDisruptionBudget.enabled }} +{{- if .Values.worker.podDisruptionBudget.enabled }} apiVersion: policy/v1 kind: PodDisruptionBudget metadata: - name: {{ .Values.engine.name }}-pdb + name: {{ .Values.worker.name }}-pdb labels: - {{- include "openops.componentLabels" (dict "root" . "component" "engine") | nindent 4 }} + {{- include "openops.componentLabels" (dict "root" . "component" "worker") | nindent 4 }} {{- with .Values.global.commonLabels }} {{- toYaml . | nindent 4 }} {{- end }} spec: - minAvailable: {{ .Values.engine.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.worker.podDisruptionBudget.minAvailable }} selector: matchLabels: - {{- include "openops.componentSelectorLabels" (dict "root" . "component" "engine") | nindent 6 }} + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "worker") | nindent 6 }} {{- end }} diff --git a/chart/templates/secret-env.yaml b/chart/templates/secret-env.yaml index 7d50968..926b7a2 100644 --- a/chart/templates/secret-env.yaml +++ b/chart/templates/secret-env.yaml @@ -10,7 +10,7 @@ {{- $existingSecret := default "" $secretSettings.existingSecret -}} {{- if and $create (not $existingSecret) -}} {{- $autoSecretData := dict -}} -{{- $envSources := list .Values.openopsEnvSecrets .Values.openopsEnv .Values.engine.env .Values.tables.env .Values.analytics.env .Values.postgres.env -}} +{{- $envSources := list .Values.openopsEnvSecrets .Values.openopsEnv .Values.worker.env .Values.tables.env .Values.analytics.env .Values.postgres.env -}} {{- range $env := $envSources }} {{- if $env }} {{- range $k, $v := $env }} diff --git a/chart/templates/service-engine.yaml b/chart/templates/service-worker.yaml similarity index 65% rename from chart/templates/service-engine.yaml rename to chart/templates/service-worker.yaml index 8fa522d..0fda388 100644 --- a/chart/templates/service-engine.yaml +++ b/chart/templates/service-worker.yaml @@ -1,15 +1,15 @@ apiVersion: v1 kind: Service metadata: - name: {{ .Values.engine.name }} + name: {{ .Values.worker.name }} labels: - {{- include "openops.componentLabels" (dict "root" . "component" "engine") | nindent 4 }} + {{- include "openops.componentLabels" (dict "root" . "component" "worker") | nindent 4 }} spec: type: ClusterIP ports: - - port: 3005 - targetPort: 3005 + - port: 3000 + targetPort: 3000 protocol: TCP name: http selector: - {{- include "openops.componentSelectorLabels" (dict "root" . "component" "engine") | nindent 4 }} \ No newline at end of file + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "worker") | nindent 4 }} \ No newline at end of file diff --git a/chart/templates/serviceaccount-engine.yaml b/chart/templates/serviceaccount-worker.yaml similarity index 68% rename from chart/templates/serviceaccount-engine.yaml rename to chart/templates/serviceaccount-worker.yaml index 461b0bf..2d1dfdb 100644 --- a/chart/templates/serviceaccount-engine.yaml +++ b/chart/templates/serviceaccount-worker.yaml @@ -1,14 +1,14 @@ -{{- if .Values.engine.serviceAccount.create }} +{{- if .Values.worker.serviceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "openops.serviceAccountName" (dict "root" . "component" "engine") }} + name: {{ include "openops.serviceAccountName" (dict "root" . "component" "worker") }} labels: - {{- include "openops.componentLabels" (dict "root" . "component" "engine") | nindent 4 }} + {{- include "openops.componentLabels" (dict "root" . "component" "worker") | nindent 4 }} {{- with .Values.global.commonLabels }} {{- toYaml . | nindent 4 }} {{- end }} - {{- with .Values.engine.serviceAccount.annotations }} + {{- with .Values.worker.serviceAccount.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} diff --git a/chart/templates/servicemonitor.yaml b/chart/templates/servicemonitor.yaml index ff16021..94cda23 100644 --- a/chart/templates/servicemonitor.yaml +++ b/chart/templates/servicemonitor.yaml @@ -20,23 +20,23 @@ spec: interval: {{ .Values.serviceMonitor.interval }} scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} --- -# ServiceMonitor for engine +# ServiceMonitor for worker apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: - name: {{ .Values.engine.name }} + name: {{ .Values.worker.name }} labels: - {{- include "openops.componentLabels" (dict "root" . "component" "engine") | nindent 4 }} + {{- include "openops.componentLabels" (dict "root" . "component" "worker") | nindent 4 }} {{- with .Values.serviceMonitor.labels }} {{- toYaml . | nindent 4 }} {{- end }} spec: selector: matchLabels: - {{- include "openops.componentSelectorLabels" (dict "root" . "component" "engine") | nindent 6 }} + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "worker") | nindent 6 }} endpoints: - port: http - path: {{ .Values.engine.metrics.path }} + path: {{ .Values.worker.metrics.path }} interval: {{ .Values.serviceMonitor.interval }} scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} --- diff --git a/chart/values.ci.yaml b/chart/values.ci.yaml index dd1fb2f..41eef3c 100644 --- a/chart/values.ci.yaml +++ b/chart/values.ci.yaml @@ -41,7 +41,7 @@ app: cpu: "500m" memory: "1Gi" -engine: +worker: replicas: 1 podDisruptionBudget: enabled: false diff --git a/chart/values.production.yaml b/chart/values.production.yaml index f88c63d..e9fd02a 100644 --- a/chart/values.production.yaml +++ b/chart/values.production.yaml @@ -64,7 +64,7 @@ app: memory: "4Gi" cpu: "2000m" -engine: +worker: replicas: 3 resources: requests: diff --git a/chart/values.schema.json b/chart/values.schema.json index fa7f547..1d2df46 100644 --- a/chart/values.schema.json +++ b/chart/values.schema.json @@ -55,7 +55,7 @@ } } }, - "engine": { + "worker": { "type": "object", "required": ["name", "image", "replicas"], "properties": { @@ -67,7 +67,7 @@ }, "repository": { "type": "string", - "description": "Override global image.repository for engine (empty = use global)" + "description": "Override global image.repository for worker (empty = use global)" }, "replicas": { "type": "integer", diff --git a/chart/values.yaml b/chart/values.yaml index 54d5790..6c55908 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -106,8 +106,8 @@ openopsEnv: OPS_TELEMETRY_MODE: COLLECTOR OPS_TELEMETRY_COLLECTOR_URL: https://telemetry.openops.com/save - # Engine - OPS_ENGINE_URL: '{{ include "openops.engineServiceUrl" . }}/execute' + # Worker + OPS_WORKER_URL: '{{ include "openops.workerServiceUrl" . }}' OPS_EXECUTION_MODE: SANDBOX_CODE_ONLY # Queue @@ -214,9 +214,9 @@ app: port: 8080 path: /metrics -engine: - name: openops-engine - image: openops-engine +worker: + name: openops-worker + image: openops-worker repository: "" # Override global image.repository (empty = use global) replicas: 2 # Minimum 2 for high availability resources: @@ -598,7 +598,7 @@ ingress: - paths: - path: / pathType: Prefix - # serviceName must match a service defined in this chart (nginx, app, engine, tables, analytics) + # serviceName must match a service defined in this chart (nginx, app, worker, tables, analytics) serviceName: nginx servicePort: 80 # TLS-related settings below apply only to nginx ingress controller From 4b953cb2f230211b0385c0a7c46274715738df65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 13 Aug 2026 12:26:34 +0100 Subject: [PATCH 2/8] Use maxUnavailable for PodDisruptionBudgets Every PDB in the chart set minAvailable, while analytics and tables default to a single replica. minAvailable: 1 against one replica evaluates to disruptionsAllowed: 0, so those pods cannot be evicted and every voluntary disruption fails: cluster upgrades, node image upgrades and autoscaler scale-down all stall while it is set. This is not theoretical. It left an Azure AKS estate on three-month-old node images across four clusters, with the pools reporting Failed and nothing alerting on it, because the drain could never complete. maxUnavailable: 1 is equivalent at two replicas and drainable at one, so all five components now default to it rather than only fixing the two that deadlock today. Overriding replicas down to 1 is a supported thing to do and should not reintroduce the deadlock. minAvailable still works when maxUnavailable is unset, so existing overrides are unaffected. A PDB may not set both; maxUnavailable takes precedence. One behaviour change to note: at three or more replicas maxUnavailable: 1 permits one pod down at a time where minAvailable: 1 permitted all but one. That is safer but makes drains slower. tables uses ReadWriteOnce storage and cannot be scaled past one replica, so it still incurs brief downtime while its node drains. This makes the drain possible, not seamless. Verified by rendering the chart: all five PDBs emit maxUnavailable: 1 with default values, and an override of maxUnavailable: null with minAvailable: 2 still emits minAvailable: 2. helm lint and both CI template steps pass. Note that CI never exercises this path, since values.ci.yaml disables PDBs. Part of OPS-4725 --- AGENTS.md | 2 +- README.md | 51 ++++++++++++++++++++++-------- chart/templates/pdb-analytics.yaml | 6 +++- chart/templates/pdb-app.yaml | 6 +++- chart/templates/pdb-engine.yaml | 6 +++- chart/templates/pdb-nginx.yaml | 6 +++- chart/templates/pdb-tables.yaml | 6 +++- chart/values.yaml | 10 +++--- docs/DEPLOY_TO_AWS_EKS.md | 27 +++++++++------- 9 files changed, 84 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e59fe0..88a6057 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ - **Security-first design**: Security contexts enabled by default (runAsNonRoot, drop ALL capabilities, seccomp RuntimeDefault profile). - **Service accounts**: Dedicated service accounts for each component (app, engine, tables, analytics, nginx, postgres, redis) with configurable annotations for AWS IAM roles (IRSA), GCP Workload Identity, or Azure Managed Identity. - **External Secrets Operator**: Built-in support for AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, and Azure Key Vault integration. -- **PodDisruptionBudgets (PDBs)**: Configured for all stateless components to ensure minimum availability during voluntary disruptions (node drains, upgrades). +- **PodDisruptionBudgets (PDBs)**: Configured for all stateless components, defaulting to `maxUnavailable: 1` so voluntary disruptions (node drains, upgrades) can always proceed. Each component also accepts `minAvailable` when `maxUnavailable` is unset, but avoid it where a component runs a single replica: `minAvailable: 1` then evaluates to `disruptionsAllowed: 0` and blocks every drain. `analytics` and `tables` default to one replica. - **HorizontalPodAutoscalers (HPAs)**: Optional autoscaling for app, engine, analytics, and nginx based on CPU/memory metrics. - **NetworkPolicy**: Optional network segmentation to restrict pod-to-pod communication and enforce least-privilege networking with explicit allow rules. - **LimitRange**: Optional namespace-level resource defaults and constraints to prevent resource exhaustion. diff --git a/README.md b/README.md index 3dc1650..da628b9 100644 --- a/README.md +++ b/README.md @@ -829,26 +829,51 @@ global: topologyKey: topology.kubernetes.io/zone whenUnsatisfiable: DoNotSchedule -# Enable PodDisruptionBudgets -pdb: - enabled: true - app: +# PodDisruptionBudgets are enabled by default with maxUnavailable: 1 +app: + podDisruptionBudget: enabled: true - minAvailable: 2 - engine: + maxUnavailable: 1 +engine: + podDisruptionBudget: enabled: true - minAvailable: 2 - nginx: + maxUnavailable: 1 +nginx: + podDisruptionBudget: enabled: true - minAvailable: 1 - analytics: + maxUnavailable: 1 +analytics: + podDisruptionBudget: enabled: true - minAvailable: 1 - tables: + maxUnavailable: 1 +tables: + podDisruptionBudget: enabled: true - minAvailable: 1 + maxUnavailable: 1 +``` + +Each component takes either `maxUnavailable` or `minAvailable` — a PodDisruptionBudget +may not set both, and `maxUnavailable` wins if you set both. + +Prefer `maxUnavailable`. `minAvailable: 1` on a component running a single replica +evaluates to `disruptionsAllowed: 0`, which makes the pod impossible to evict and blocks +every node drain — cluster upgrades, node image upgrades and autoscaler scale-down all +fail while it is set. `analytics` and `tables` default to one replica, and `tables` uses +`ReadWriteOnce` storage so it cannot be scaled out of the problem. + +To use `minAvailable` instead, unset `maxUnavailable` explicitly: + +```yaml +app: + podDisruptionBudget: + maxUnavailable: null + minAvailable: 2 ``` +Note that `maxUnavailable: 1` allows only one pod down at a time. At three or more +replicas that is stricter than `minAvailable: 1`, which permits all but one to go at +once — safer, but drains take longer. + ### Monitoring and observability **Prometheus metrics:** diff --git a/chart/templates/pdb-analytics.yaml b/chart/templates/pdb-analytics.yaml index 369f90d..85b74ba 100644 --- a/chart/templates/pdb-analytics.yaml +++ b/chart/templates/pdb-analytics.yaml @@ -9,7 +9,11 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - minAvailable: {{ .Values.analytics.podDisruptionBudget.minAvailable }} + {{- if .Values.analytics.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.analytics.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.analytics.podDisruptionBudget.minAvailable | default 1 }} + {{- end }} selector: matchLabels: {{- include "openops.componentSelectorLabels" (dict "root" . "component" "analytics") | nindent 6 }} diff --git a/chart/templates/pdb-app.yaml b/chart/templates/pdb-app.yaml index 12f9064..5180d26 100644 --- a/chart/templates/pdb-app.yaml +++ b/chart/templates/pdb-app.yaml @@ -9,7 +9,11 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - minAvailable: {{ .Values.app.podDisruptionBudget.minAvailable }} + {{- if .Values.app.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.app.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.app.podDisruptionBudget.minAvailable | default 1 }} + {{- end }} selector: matchLabels: {{- include "openops.componentSelectorLabels" (dict "root" . "component" "app") | nindent 6 }} diff --git a/chart/templates/pdb-engine.yaml b/chart/templates/pdb-engine.yaml index b0e670e..51b82aa 100644 --- a/chart/templates/pdb-engine.yaml +++ b/chart/templates/pdb-engine.yaml @@ -9,7 +9,11 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - minAvailable: {{ .Values.engine.podDisruptionBudget.minAvailable }} + {{- if .Values.engine.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.engine.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.engine.podDisruptionBudget.minAvailable | default 1 }} + {{- end }} selector: matchLabels: {{- include "openops.componentSelectorLabels" (dict "root" . "component" "engine") | nindent 6 }} diff --git a/chart/templates/pdb-nginx.yaml b/chart/templates/pdb-nginx.yaml index d452c88..85bf478 100644 --- a/chart/templates/pdb-nginx.yaml +++ b/chart/templates/pdb-nginx.yaml @@ -9,7 +9,11 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - minAvailable: {{ .Values.nginx.podDisruptionBudget.minAvailable }} + {{- if .Values.nginx.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.nginx.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.nginx.podDisruptionBudget.minAvailable | default 1 }} + {{- end }} selector: matchLabels: {{- include "openops.componentSelectorLabels" (dict "root" . "component" "nginx") | nindent 6 }} diff --git a/chart/templates/pdb-tables.yaml b/chart/templates/pdb-tables.yaml index b3a7cfa..368bf27 100644 --- a/chart/templates/pdb-tables.yaml +++ b/chart/templates/pdb-tables.yaml @@ -9,7 +9,11 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - minAvailable: {{ .Values.tables.podDisruptionBudget.minAvailable }} + {{- if .Values.tables.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.tables.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.tables.podDisruptionBudget.minAvailable | default 1 }} + {{- end }} selector: matchLabels: {{- include "openops.componentSelectorLabels" (dict "root" . "component" "tables") | nindent 6 }} diff --git a/chart/values.yaml b/chart/values.yaml index 139198f..ef6990a 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -193,7 +193,7 @@ app: # Pod Disruption Budget - ENABLED BY DEFAULT podDisruptionBudget: enabled: true - minAvailable: 1 + maxUnavailable: 1 # Horizontal Pod Autoscaler autoscaling: enabled: false @@ -229,7 +229,7 @@ engine: # Pod Disruption Budget - ENABLED BY DEFAULT podDisruptionBudget: enabled: true - minAvailable: 1 + maxUnavailable: 1 # Horizontal Pod Autoscaler autoscaling: enabled: false @@ -269,7 +269,7 @@ tables: # Pod Disruption Budget podDisruptionBudget: enabled: true - minAvailable: 1 + maxUnavailable: 1 # Horizontal Pod Autoscaler autoscaling: enabled: false @@ -332,7 +332,7 @@ analytics: # Pod Disruption Budget podDisruptionBudget: enabled: true - minAvailable: 1 + maxUnavailable: 1 # Horizontal Pod Autoscaler autoscaling: enabled: false @@ -545,7 +545,7 @@ nginx: # Pod Disruption Budget - ENABLED BY DEFAULT podDisruptionBudget: enabled: true - minAvailable: 1 + maxUnavailable: 1 # Horizontal Pod Autoscaler autoscaling: enabled: false diff --git a/docs/DEPLOY_TO_AWS_EKS.md b/docs/DEPLOY_TO_AWS_EKS.md index 3d17f15..254ca3c 100644 --- a/docs/DEPLOY_TO_AWS_EKS.md +++ b/docs/DEPLOY_TO_AWS_EKS.md @@ -751,23 +751,26 @@ nginx: # service.beta.kubernetes.io/aws-load-balancer-internal: "true" # Pod Disruption Budgets for HA -pdb: - enabled: true - app: +app: + podDisruptionBudget: enabled: true - minAvailable: 2 - engine: + maxUnavailable: 1 +engine: + podDisruptionBudget: enabled: true - minAvailable: 2 - nginx: + maxUnavailable: 1 +nginx: + podDisruptionBudget: enabled: true - minAvailable: 1 - analytics: + maxUnavailable: 1 +analytics: + podDisruptionBudget: enabled: true - minAvailable: 1 - tables: + maxUnavailable: 1 +tables: + podDisruptionBudget: enabled: true - minAvailable: 1 + maxUnavailable: 1 # Horizontal Pod Autoscaling hpa: From 5bcde25174365e73ced453a269fa26039a66ac48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 13 Aug 2026 12:36:30 +0100 Subject: [PATCH 3/8] Respect an explicit maxUnavailable of zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template guarded on truthiness, and Go templates treat 0 as false, so maxUnavailable: 0 fell through to rendering minAvailable: 1 — silently producing a PDB that differs from the values that asked for it. 0 is a valid PodDisruptionBudget value. kindIs "invalid" tests for nil instead, which keeps all four cases correct: an explicit 0 renders as 0, an explicit null falls back to minAvailable, an absent key falls back, and a set value renders. hasKey would not work here, since it is true for maxUnavailable: null and would render an empty field. Also correct two documentation errors. The README claimed a PDB "may not set both" fields and then that maxUnavailable "wins if you set both", conflating the rendered resource with the values schema. AGENTS.md described PDBs as covering "all stateless components" while tables, which has one, is stateful. Part of OPS-4725 --- AGENTS.md | 2 +- README.md | 7 ++++--- chart/templates/pdb-analytics.yaml | 2 +- chart/templates/pdb-app.yaml | 2 +- chart/templates/pdb-engine.yaml | 2 +- chart/templates/pdb-nginx.yaml | 2 +- chart/templates/pdb-tables.yaml | 2 +- 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88a6057..7e5dee5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ - **Security-first design**: Security contexts enabled by default (runAsNonRoot, drop ALL capabilities, seccomp RuntimeDefault profile). - **Service accounts**: Dedicated service accounts for each component (app, engine, tables, analytics, nginx, postgres, redis) with configurable annotations for AWS IAM roles (IRSA), GCP Workload Identity, or Azure Managed Identity. - **External Secrets Operator**: Built-in support for AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, and Azure Key Vault integration. -- **PodDisruptionBudgets (PDBs)**: Configured for all stateless components, defaulting to `maxUnavailable: 1` so voluntary disruptions (node drains, upgrades) can always proceed. Each component also accepts `minAvailable` when `maxUnavailable` is unset, but avoid it where a component runs a single replica: `minAvailable: 1` then evaluates to `disruptionsAllowed: 0` and blocks every drain. `analytics` and `tables` default to one replica. +- **PodDisruptionBudgets (PDBs)**: Configured for `app`, `engine`, `nginx`, `analytics` and `tables` — note that `tables` is stateful (PVC, ReadWriteOnce), while the Postgres and Redis StatefulSets have no PDB. All default to `maxUnavailable: 1` so voluntary disruptions (node drains, upgrades) can always proceed. Each component also accepts `minAvailable` when `maxUnavailable` is unset, but avoid it where a component runs a single replica: `minAvailable: 1` then evaluates to `disruptionsAllowed: 0` and blocks every drain. `analytics` and `tables` default to one replica. - **HorizontalPodAutoscalers (HPAs)**: Optional autoscaling for app, engine, analytics, and nginx based on CPU/memory metrics. - **NetworkPolicy**: Optional network segmentation to restrict pod-to-pod communication and enforce least-privilege networking with explicit allow rules. - **LimitRange**: Optional namespace-level resource defaults and constraints to prevent resource exhaustion. diff --git a/README.md b/README.md index da628b9..b58f20c 100644 --- a/README.md +++ b/README.md @@ -852,8 +852,9 @@ tables: maxUnavailable: 1 ``` -Each component takes either `maxUnavailable` or `minAvailable` — a PodDisruptionBudget -may not set both, and `maxUnavailable` wins if you set both. +A PodDisruptionBudget resource carries only one of the two fields. The chart renders +`maxUnavailable` whenever that value is non-nil, and `minAvailable` otherwise; +`maxUnavailable: 0` counts as set and is rendered as `0`. Prefer `maxUnavailable`. `minAvailable: 1` on a component running a single replica evaluates to `disruptionsAllowed: 0`, which makes the pod impossible to evict and blocks @@ -861,7 +862,7 @@ every node drain — cluster upgrades, node image upgrades and autoscaler scale- fail while it is set. `analytics` and `tables` default to one replica, and `tables` uses `ReadWriteOnce` storage so it cannot be scaled out of the problem. -To use `minAvailable` instead, unset `maxUnavailable` explicitly: +To use `minAvailable` instead, set `maxUnavailable` to `null` explicitly: ```yaml app: diff --git a/chart/templates/pdb-analytics.yaml b/chart/templates/pdb-analytics.yaml index 85b74ba..87245ad 100644 --- a/chart/templates/pdb-analytics.yaml +++ b/chart/templates/pdb-analytics.yaml @@ -9,7 +9,7 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if .Values.analytics.podDisruptionBudget.maxUnavailable }} + {{- if not (kindIs "invalid" .Values.analytics.podDisruptionBudget.maxUnavailable) }} maxUnavailable: {{ .Values.analytics.podDisruptionBudget.maxUnavailable }} {{- else }} minAvailable: {{ .Values.analytics.podDisruptionBudget.minAvailable | default 1 }} diff --git a/chart/templates/pdb-app.yaml b/chart/templates/pdb-app.yaml index 5180d26..6df94f4 100644 --- a/chart/templates/pdb-app.yaml +++ b/chart/templates/pdb-app.yaml @@ -9,7 +9,7 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if .Values.app.podDisruptionBudget.maxUnavailable }} + {{- if not (kindIs "invalid" .Values.app.podDisruptionBudget.maxUnavailable) }} maxUnavailable: {{ .Values.app.podDisruptionBudget.maxUnavailable }} {{- else }} minAvailable: {{ .Values.app.podDisruptionBudget.minAvailable | default 1 }} diff --git a/chart/templates/pdb-engine.yaml b/chart/templates/pdb-engine.yaml index 51b82aa..e3a2829 100644 --- a/chart/templates/pdb-engine.yaml +++ b/chart/templates/pdb-engine.yaml @@ -9,7 +9,7 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if .Values.engine.podDisruptionBudget.maxUnavailable }} + {{- if not (kindIs "invalid" .Values.engine.podDisruptionBudget.maxUnavailable) }} maxUnavailable: {{ .Values.engine.podDisruptionBudget.maxUnavailable }} {{- else }} minAvailable: {{ .Values.engine.podDisruptionBudget.minAvailable | default 1 }} diff --git a/chart/templates/pdb-nginx.yaml b/chart/templates/pdb-nginx.yaml index 85bf478..261a6d9 100644 --- a/chart/templates/pdb-nginx.yaml +++ b/chart/templates/pdb-nginx.yaml @@ -9,7 +9,7 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if .Values.nginx.podDisruptionBudget.maxUnavailable }} + {{- if not (kindIs "invalid" .Values.nginx.podDisruptionBudget.maxUnavailable) }} maxUnavailable: {{ .Values.nginx.podDisruptionBudget.maxUnavailable }} {{- else }} minAvailable: {{ .Values.nginx.podDisruptionBudget.minAvailable | default 1 }} diff --git a/chart/templates/pdb-tables.yaml b/chart/templates/pdb-tables.yaml index 368bf27..fe63e3b 100644 --- a/chart/templates/pdb-tables.yaml +++ b/chart/templates/pdb-tables.yaml @@ -9,7 +9,7 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if .Values.tables.podDisruptionBudget.maxUnavailable }} + {{- if not (kindIs "invalid" .Values.tables.podDisruptionBudget.maxUnavailable) }} maxUnavailable: {{ .Values.tables.podDisruptionBudget.maxUnavailable }} {{- else }} minAvailable: {{ .Values.tables.podDisruptionBudget.minAvailable | default 1 }} From 1118c7998202a95d3b340c089dadff8b77657729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 13 Aug 2026 12:47:29 +0100 Subject: [PATCH 4/8] Fix lint issues --- chart/templates/pdb-worker.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/chart/templates/pdb-worker.yaml b/chart/templates/pdb-worker.yaml index 91cc646..60d023e 100644 --- a/chart/templates/pdb-worker.yaml +++ b/chart/templates/pdb-worker.yaml @@ -9,10 +9,10 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if not (kindIs "invalid" .Values.engine.podDisruptionBudget.maxUnavailable) }} - maxUnavailable: {{ .Values.engine.podDisruptionBudget.maxUnavailable }} + {{- if not (kindIs "invalid" .Values.worker.podDisruptionBudget.maxUnavailable) }} + maxUnavailable: {{ .Values.worker.podDisruptionBudget.maxUnavailable }} {{- else }} - minAvailable: {{ .Values.engine.podDisruptionBudget.minAvailable | default 1 }} + minAvailable: {{ .Values.worker.podDisruptionBudget.minAvailable | default 1 }} {{- end }} selector: matchLabels: From 70da0d44a786e586ab8d264fc4f543ca2cdd84e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 10 Sep 2026 09:07:41 +0100 Subject: [PATCH 5/8] Publish the chart and its images from ACR (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e is red for a reason that predates this PR: no released openops-worker image exists in any public registry, and the last released openops-app (0.6.25) predates the engine→worker rename this branch carries, so it crash-loops on OPS_ENGINE_URL. It clears with the 0.6.26 release. --- .github/workflows/helm-e2e.yaml | 8 +- .github/workflows/helm-validate.yaml | 4 +- .github/workflows/release.yml | 39 ++-- AGENTS.md | 10 +- README.md | 35 +--- chart/templates/NOTES.txt | 2 +- .../cronjob-ecr-credential-refresh.yaml | 184 ------------------ chart/values.ci.yaml | 2 +- chart/values.schema.json | 4 +- chart/values.yaml | 29 +-- docs/DEPLOY_TO_AWS_EKS.md | 2 +- docs/DEPLOY_TO_AWS_EKS_FARGATE.md | 2 +- 12 files changed, 58 insertions(+), 263 deletions(-) delete mode 100644 chart/templates/cronjob-ecr-credential-refresh.yaml diff --git a/.github/workflows/helm-e2e.yaml b/.github/workflows/helm-e2e.yaml index 00ae2db..2be662d 100644 --- a/.github/workflows/helm-e2e.yaml +++ b/.github/workflows/helm-e2e.yaml @@ -20,15 +20,15 @@ jobs: RELEASE_NAME: openops NAMESPACE: openops steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.1 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5.0.1 with: version: v3.14.4 - name: Cache Docker images - uses: actions/cache@v4 + uses: actions/cache@v6.1.0 with: path: /var/lib/docker key: docker-${{ runner.os }}-${{ hashFiles('chart/values.yaml', 'chart/values.ci.yaml') }} @@ -36,7 +36,7 @@ jobs: docker-${{ runner.os }}- - name: Create kind cluster - uses: helm/kind-action@v1.9.0 + uses: helm/kind-action@v1.15.0 with: cluster_name: openops-e2e wait: 180s diff --git a/.github/workflows/helm-validate.yaml b/.github/workflows/helm-validate.yaml index 624b860..bb90573 100644 --- a/.github/workflows/helm-validate.yaml +++ b/.github/workflows/helm-validate.yaml @@ -18,10 +18,10 @@ jobs: contents: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5.0.1 with: version: v3.14.4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63a0b74..2ac30ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,15 +20,20 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write + # The release identity's federated credentials are scoped to this environment rather than to + # a branch, so a release runs from any branch - and the environment is where an approval gate + # for publishing to the public registry belongs. Without it the OIDC subject does not match. + environment: release env: CHART_VERSION: ${{ inputs.draft && format('{0}-draft', inputs.version) || inputs.version }} APP_VERSION: ${{ inputs.version }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@v5.0.1 with: version: v3.14.4 @@ -54,23 +59,29 @@ jobs: chart_package=$(printf '%s\n' "$pkg_output" | awk '/Successfully packaged chart and saved it to:/ {print $NF}') echo "CHART_PACKAGE=$chart_package" >> "$GITHUB_ENV" - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Azure login + uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: - aws-access-key-id: ${{ secrets.ECR_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.ECR_SECRET_ACCESS_KEY }} - aws-region: ${{ vars.ECR_PUBLIC_REGION }} + client-id: ${{ secrets.AZURE_ACR_RELEASE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Push chart to public ECR + - name: Push chart to the public ACR + env: + ACR_PUBLIC_NAME: ${{ vars.ACR_PUBLIC_NAME }} + ACR_PUBLIC_LOGIN_SERVER: ${{ vars.ACR_PUBLIC_LOGIN_SERVER }} run: | set -euo pipefail - aws ecr-public get-login-password --region ${{ vars.ECR_PUBLIC_REGION }} \ - | helm registry login --username AWS --password-stdin public.ecr.aws - helm push "$CHART_PACKAGE" oci://public.ecr.aws/openops/helm - echo "- ✅ Pushed \`oci://public.ecr.aws/openops/helm/openops:${CHART_VERSION}\`" >> $GITHUB_STEP_SUMMARY + # --expose-token rather than plain `az acr login`: that writes Docker's credential + # store, which helm does not read. helm wants its own registry login. + TOKEN=$(az acr login --name "$ACR_PUBLIC_NAME" --expose-token --output tsv --query accessToken) + helm registry login "$ACR_PUBLIC_LOGIN_SERVER" \ + --username 00000000-0000-0000-0000-000000000000 --password-stdin <<<"$TOKEN" + helm push "$CHART_PACKAGE" "oci://$ACR_PUBLIC_LOGIN_SERVER/helm" + echo "- ✅ Pushed \`oci://$ACR_PUBLIC_LOGIN_SERVER/helm/openops:${CHART_VERSION}\`" >> $GITHUB_STEP_SUMMARY - name: Create GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3.0.3 with: target_commitish: ${{ github.sha }} tag_name: ${{ env.CHART_VERSION }} @@ -91,5 +102,5 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "Install with:" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "helm install openops oci://public.ecr.aws/openops/helm/openops --version $CHART_VERSION" >> $GITHUB_STEP_SUMMARY + echo "helm install openops oci://${{ vars.ACR_PUBLIC_LOGIN_SERVER }}/helm/openops --version $CHART_VERSION" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/AGENTS.md b/AGENTS.md index 7e5dee5..e93d736 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,21 +35,21 @@ - **Validation helpers**: Runtime validation of required secrets (OPS_ENCRYPTION_KEY, OPS_JWT_SECRET, etc.) with helpful error messages at render time. ## Release workflow -- **`.github/workflows/release.yml`**: Packages the Helm chart and pushes it as an OCI artifact to `public.ecr.aws/openops/helm/openops`. +- **`.github/workflows/release.yml`**: Packages the Helm chart and pushes it as an OCI artifact to `openops.azurecr.io/helm/openops`. - Triggered via `workflow_dispatch` with two inputs: - `version` (required): The release version (e.g., `0.6.15`). Sets both `Chart.yaml` version/appVersion and `global.version` (image tags). - - `draft` (boolean, default `true`): When true, appends `-draft` to the chart version (e.g., `0.6.15-draft`). Draft versions are overwritable on ECR; final versions are immutable. + - `draft` (boolean, default `true`): When true, appends `-draft` to the chart version (e.g., `0.6.15-draft`). The suffix is a naming convention only - ACR does not enforce tag immutability unless it is configured on the registry, so a final version can be overwritten by a later push of the same version. - Also triggered cross-repo by `openops-cloud/openops` release workflow. - Creates a GitHub release (draft or published) with the packaged `.tgz` as an asset. - **Do not bump versions in `Chart.yaml` or `values.yaml` manually**—the release workflow sets them at build time. The repo defaults are `version: 0.0.1-dev` and `appVersion: 0.0.1-dev`. -- Required secrets: `ECR_ACCESS_KEY_ID`, `ECR_SECRET_ACCESS_KEY`; required vars: `ECR_PUBLIC_REGION`. +- Authenticates with GitHub OIDC. Required secrets: `AZURE_ACR_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`; required vars: `ACR_PUBLIC_NAME`, `ACR_PUBLIC_LOGIN_SERVER`. ## Versioning strategy - All *release* versions are unified: chart version = appVersion = `global.version` (image tags) = OpenOps release version. The in-repo development defaults (`0.0.1-dev`) are normalized by the release workflow. - Exception: draft releases use `{version}-draft` for the chart version only; `appVersion` and image tags use the clean version. -- The chart is published to `oci://public.ecr.aws/openops/helm/openops`. Users install with: +- The chart is published to `oci://openops.azurecr.io/helm/openops`. Users install with: ``` - helm install openops oci://public.ecr.aws/openops/helm/openops --version + helm install openops oci://openops.azurecr.io/helm/openops --version ``` ## PR lint rules diff --git a/README.md b/README.md index b58f20c..d5b856f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ This repository contains the Helm chart that deploys the OpenOps application sta ### Install from OCI registry (recommended) ```bash -helm install openops oci://public.ecr.aws/openops/helm/openops \ +helm install openops oci://openops.azurecr.io/helm/openops \ --version \ -n openops --create-namespace \ -f values.overrides.yaml @@ -315,38 +315,23 @@ kubectl create secret tls openops-tls \ ## Dependencies The deployments include health checks and readiness probes so dependent services wait until their prerequisites are available. -## Private ECR access from non-AWS clusters +## Pulling from a private registry -When deploying on AKS or other non-AWS Kubernetes clusters that need to pull images from a private ECR registry, enable the ECR credential refresh CronJob: +Release images need no credentials. `openops.azurecr.io` allows anonymous pull, so a default +install authenticates to nothing. + +If you mirror the images into a registry of your own, create a pull secret by whatever means +that registry expects and reference it: ```yaml global: imagePullSecrets: - - ecr-pull-secret - -ecrCredentialRefresh: - enabled: true - registry: "" - awsRegion: "us-east-2" - awsSecretName: "ecr-credentials" # K8s secret with AWS access keys - imagePullSecretName: "ecr-pull-secret" # Created/refreshed automatically -``` + - my-registry-credentials -**Pre-requisite:** Create the AWS credentials secret (one-time): -```bash -kubectl create secret generic ecr-credentials \ - -n openops \ - --from-literal=AWS_ACCESS_KEY_ID= \ - --from-literal=AWS_SECRET_ACCESS_KEY= +image: + repository: my-registry.example.com/openops ``` -The IAM user needs only `ecr:GetAuthorizationToken`, `ecr:BatchGetImage`, and `ecr:GetDownloadUrlForLayer` permissions. - -**How it works:** -- A Helm post-install/post-upgrade hook creates the pull secret immediately on deploy -- A CronJob refreshes the ECR token every 6 hours (tokens expire after 12h) -- All deployments reference the pull secret via `global.imagePullSecrets` - ## Analytics (Superset) configuration override The analytics component is based on Apache Superset. To override its Python configuration (e.g., for Redis SSL/auth), use `analytics.configOverride`: diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 03312f6..736f396 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -118,7 +118,7 @@ Useful commands: helm test {{ .Release.Name }} -n {{ .Release.Namespace }} # Upgrade deployment - helm upgrade {{ .Release.Name }} oci://public.ecr.aws/openops/helm/{{ .Chart.Name }} --version {{ .Chart.Version }} -n {{ .Release.Namespace }} -f values.overrides.yaml + helm upgrade {{ .Release.Name }} oci://openops.azurecr.io/helm/{{ .Chart.Name }} --version {{ .Chart.Version }} -n {{ .Release.Namespace }} -f values.overrides.yaml # Uninstall helm uninstall {{ .Release.Name }} -n {{ .Release.Namespace }} diff --git a/chart/templates/cronjob-ecr-credential-refresh.yaml b/chart/templates/cronjob-ecr-credential-refresh.yaml deleted file mode 100644 index da2abf2..0000000 --- a/chart/templates/cronjob-ecr-credential-refresh.yaml +++ /dev/null @@ -1,184 +0,0 @@ -{{- if .Values.ecrCredentialRefresh.enabled }} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ .Values.ecrCredentialRefresh.serviceAccount.name | default "ecr-credential-refresh" }} - namespace: {{ .Release.Namespace }} - {{- with .Values.ecrCredentialRefresh.serviceAccount.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: ecr-credential-refresh - namespace: {{ .Release.Namespace }} -rules: - - apiGroups: [""] - resources: ["secrets"] - resourceNames: [{{ .Values.ecrCredentialRefresh.imagePullSecretName | default "ecr-pull-secret" | quote }}] - verbs: ["get", "patch", "delete"] - - apiGroups: [""] - resources: ["secrets"] - verbs: ["create"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: ecr-credential-refresh - namespace: {{ .Release.Namespace }} -subjects: - - kind: ServiceAccount - name: {{ .Values.ecrCredentialRefresh.serviceAccount.name | default "ecr-credential-refresh" }} - namespace: {{ .Release.Namespace }} -roleRef: - kind: Role - name: ecr-credential-refresh - apiGroup: rbac.authorization.k8s.io ---- -apiVersion: batch/v1 -kind: CronJob -metadata: - name: ecr-credential-refresh - namespace: {{ .Release.Namespace }} - labels: - app.kubernetes.io/name: ecr-credential-refresh - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - schedule: "0 */6 * * *" - successfulJobsHistoryLimit: 2 - failedJobsHistoryLimit: 3 - concurrencyPolicy: Forbid - jobTemplate: - spec: - backoffLimit: 3 - template: - spec: - serviceAccountName: {{ .Values.ecrCredentialRefresh.serviceAccount.name | default "ecr-credential-refresh" }} - restartPolicy: OnFailure - containers: - - name: ecr-credential-refresh - image: amazon/aws-cli:2.27.18 - env: - - name: AWS_ACCESS_KEY_ID - valueFrom: - secretKeyRef: - name: {{ .Values.ecrCredentialRefresh.awsSecretName | default "ecr-credentials" }} - key: AWS_ACCESS_KEY_ID - - name: AWS_SECRET_ACCESS_KEY - valueFrom: - secretKeyRef: - name: {{ .Values.ecrCredentialRefresh.awsSecretName | default "ecr-credentials" }} - key: AWS_SECRET_ACCESS_KEY - - name: AWS_REGION - value: {{ .Values.ecrCredentialRefresh.awsRegion | quote }} - - name: ECR_REGISTRY - value: {{ required "ecrCredentialRefresh.registry is required when ecrCredentialRefresh is enabled" .Values.ecrCredentialRefresh.registry | quote }} - - name: SECRET_NAME - value: {{ .Values.ecrCredentialRefresh.imagePullSecretName | default "ecr-pull-secret" | quote }} - - name: NAMESPACE - value: {{ .Release.Namespace }} - command: - - /bin/bash - - -c - - | - set -euo pipefail - KUBECTL_VERSION="v1.32.3" - ARCH=$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') - if [[ "$ARCH" != "amd64" && "$ARCH" != "arm64" ]]; then - echo "❌ Unsupported architecture: $(uname -m)" >&2; exit 1 - fi - curl -fSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" - curl -fSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl.sha256" - echo "$(cat kubectl.sha256) kubectl" | sha256sum -c - - rm kubectl.sha256 - chmod +x kubectl && mv kubectl /usr/local/bin/ - TOKEN=$(aws ecr get-login-password --region "$AWS_REGION") - kubectl create secret docker-registry "$SECRET_NAME" \ - -n "$NAMESPACE" \ - --docker-server="$ECR_REGISTRY" \ - --docker-username=AWS \ - --docker-password="$TOKEN" \ - --dry-run=client -o yaml | kubectl apply -f - - echo "✅ ECR pull secret refreshed successfully" - resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "128Mi" - cpu: "100m" ---- -# Initial Job to create the secret immediately on install/upgrade -apiVersion: batch/v1 -kind: Job -metadata: - name: ecr-credential-refresh-init - namespace: {{ .Release.Namespace }} - annotations: - "helm.sh/hook": post-install,post-upgrade - "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded - labels: - app.kubernetes.io/name: ecr-credential-refresh - app.kubernetes.io/instance: {{ .Release.Name }} -spec: - backoffLimit: 3 - template: - spec: - serviceAccountName: {{ .Values.ecrCredentialRefresh.serviceAccount.name | default "ecr-credential-refresh" }} - restartPolicy: OnFailure - containers: - - name: ecr-credential-refresh - image: amazon/aws-cli:2.27.18 - env: - - name: AWS_ACCESS_KEY_ID - valueFrom: - secretKeyRef: - name: {{ .Values.ecrCredentialRefresh.awsSecretName | default "ecr-credentials" }} - key: AWS_ACCESS_KEY_ID - - name: AWS_SECRET_ACCESS_KEY - valueFrom: - secretKeyRef: - name: {{ .Values.ecrCredentialRefresh.awsSecretName | default "ecr-credentials" }} - key: AWS_SECRET_ACCESS_KEY - - name: AWS_REGION - value: {{ .Values.ecrCredentialRefresh.awsRegion | quote }} - - name: ECR_REGISTRY - value: {{ required "ecrCredentialRefresh.registry is required when ecrCredentialRefresh is enabled" .Values.ecrCredentialRefresh.registry | quote }} - - name: SECRET_NAME - value: {{ .Values.ecrCredentialRefresh.imagePullSecretName | default "ecr-pull-secret" | quote }} - - name: NAMESPACE - value: {{ .Release.Namespace }} - command: - - /bin/bash - - -c - - | - set -euo pipefail - KUBECTL_VERSION="v1.32.3" - ARCH=$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') - if [[ "$ARCH" != "amd64" && "$ARCH" != "arm64" ]]; then - echo "❌ Unsupported architecture: $(uname -m)" >&2; exit 1 - fi - curl -fSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" - curl -fSLO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl.sha256" - echo "$(cat kubectl.sha256) kubectl" | sha256sum -c - - rm kubectl.sha256 - chmod +x kubectl && mv kubectl /usr/local/bin/ - TOKEN=$(aws ecr get-login-password --region "$AWS_REGION") - kubectl create secret docker-registry "$SECRET_NAME" \ - -n "$NAMESPACE" \ - --docker-server="$ECR_REGISTRY" \ - --docker-username=AWS \ - --docker-password="$TOKEN" \ - --dry-run=client -o yaml | kubectl apply -f - - echo "✅ ECR pull secret created successfully" - resources: - requests: - memory: "64Mi" - cpu: "50m" - limits: - memory: "128Mi" - cpu: "100m" -{{- end }} diff --git a/chart/values.ci.yaml b/chart/values.ci.yaml index 41eef3c..3f82918 100644 --- a/chart/values.ci.yaml +++ b/chart/values.ci.yaml @@ -3,7 +3,7 @@ # Includes test secrets - NOT FOR PRODUCTION global: - # Use latest published images for CI (repo default 0.0.1-dev does not exist in ECR) + # Use latest published images for CI (repo default 0.0.1-dev is never published) version: "latest" # Allow single replica for CI diff --git a/chart/values.schema.json b/chart/values.schema.json index 1d2df46..0a1df5a 100644 --- a/chart/values.schema.json +++ b/chart/values.schema.json @@ -87,7 +87,7 @@ }, "repository": { "type": "string", - "description": "Image repository for tables (defaults to public.ecr.aws/openops)" + "description": "Image repository for tables (defaults to openops.azurecr.io)" }, "tag": { "type": "string", @@ -112,7 +112,7 @@ }, "repository": { "type": "string", - "description": "Image repository for analytics (defaults to public.ecr.aws/openops)" + "description": "Image repository for analytics (defaults to openops.azurecr.io)" }, "tag": { "type": "string", diff --git a/chart/values.yaml b/chart/values.yaml index 8b791b6..a2a70d5 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -81,7 +81,7 @@ global: priorityClassName: "" image: - repository: public.ecr.aws/openops + repository: openops.azurecr.io pullPolicy: IfNotPresent openopsEnv: @@ -149,7 +149,7 @@ openopsEnv: OPS_SUBAGENT_S3_BUCKET: "" OPS_SUBAGENT_S3_REGION: "" OPS_SUBAGENT_S3_ENDPOINT: "" - OPS_SUBAGENT_RUNNER_IMAGE: "535002847982.dkr.ecr.us-east-2.amazonaws.com/openops/subagent-runner:0fdbc6b1" + OPS_SUBAGENT_RUNNER_IMAGE: "" # Secret environment variables # Any var in this section is treated as a secret (stored in K8s Secret, referenced via secretKeyRef). @@ -256,8 +256,8 @@ worker: tables: name: openops-tables image: openops-tables - repository: "public.ecr.aws/openops" # Always use public ECR for tables - tag: "0.2.21" + repository: "openops.azurecr.io" # Always the public registry for tables + tag: "0.2.22" replicas: 1 resources: requests: @@ -319,8 +319,8 @@ tables: analytics: name: openops-analytics image: openops-analytics - repository: "public.ecr.aws/openops" # Always use public ECR for analytics - tag: "0.14.7" + repository: "openops.azurecr.io" # Always the public registry for analytics + tag: "0.14.8" replicas: 1 resources: requests: @@ -654,23 +654,6 @@ externalSecrets: annotations: {} labels: {} -# ECR credential refresh CronJob (for pulling from private ECR outside AWS) -# Creates and refreshes an imagePullSecret every 6 hours using AWS ECR token. -# Required when AKS or non-AWS clusters need to pull from private ECR. -ecrCredentialRefresh: - enabled: false - registry: "" - # AWS region of the ECR registry - awsRegion: "us-east-2" - # Name of the Kubernetes secret containing AWS credentials (keys: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) - awsSecretName: "ecr-credentials" - # Name of the imagePullSecret that will be created/refreshed - imagePullSecretName: "ecr-pull-secret" - serviceAccount: - name: "ecr-credential-refresh" - annotations: {} - -# Subagent configuration subagents: # Namespace where subagent pods run (defaults to same namespace as app) namespace: "" diff --git a/docs/DEPLOY_TO_AWS_EKS.md b/docs/DEPLOY_TO_AWS_EKS.md index 254ca3c..d3b3ae7 100644 --- a/docs/DEPLOY_TO_AWS_EKS.md +++ b/docs/DEPLOY_TO_AWS_EKS.md @@ -617,7 +617,7 @@ global: enabled: true image: - repository: public.ecr.aws/openops + repository: openops.azurecr.io pullPolicy: IfNotPresent openopsEnv: diff --git a/docs/DEPLOY_TO_AWS_EKS_FARGATE.md b/docs/DEPLOY_TO_AWS_EKS_FARGATE.md index 8867074..a5e06cb 100644 --- a/docs/DEPLOY_TO_AWS_EKS_FARGATE.md +++ b/docs/DEPLOY_TO_AWS_EKS_FARGATE.md @@ -677,7 +677,7 @@ global: whenUnsatisfiable: ScheduleAnyway image: - repository: public.ecr.aws/openops + repository: openops.azurecr.io pullPolicy: IfNotPresent # Environment configuration From 7a78ea7d4732720c28ce9e9c2a0860128177003c Mon Sep 17 00:00:00 2001 From: Ravi Kiran Vallamkonda <36404296+ravikiranvm@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:09:51 +0530 Subject: [PATCH 6/8] Add MCP Service to Helm chart (#37) --- .github/workflows/helm-validate.yaml | 3 + AGENTS.md | 10 +-- README.md | 66 +++++++++++++++----- chart/.gitignore | 1 + chart/templates/NOTES.txt | 8 +++ chart/templates/_helpers.tpl | 30 ++++++++- chart/templates/configmap-nginx.yaml | 48 +++++++++++++++ chart/templates/deployment-mcp.yaml | 82 +++++++++++++++++++++++++ chart/templates/external-secret.yaml | 4 ++ chart/templates/hpa-mcp.yaml | 35 +++++++++++ chart/templates/networkpolicy.yaml | 55 +++++++++++++++++ chart/templates/pdb-mcp.yaml | 20 ++++++ chart/templates/secret-env.yaml | 3 + chart/templates/service-mcp.yaml | 17 +++++ chart/templates/serviceaccount-mcp.yaml | 15 +++++ chart/values.ci.yaml | 15 +++++ chart/values.mcp-example.yaml | 28 +++++++++ chart/values.schema.json | 33 ++++++++++ chart/values.yaml | 66 ++++++++++++++++++++ 19 files changed, 519 insertions(+), 20 deletions(-) create mode 100644 chart/templates/deployment-mcp.yaml create mode 100644 chart/templates/hpa-mcp.yaml create mode 100644 chart/templates/pdb-mcp.yaml create mode 100644 chart/templates/service-mcp.yaml create mode 100644 chart/templates/serviceaccount-mcp.yaml create mode 100644 chart/values.mcp-example.yaml diff --git a/.github/workflows/helm-validate.yaml b/.github/workflows/helm-validate.yaml index bb90573..0e1dd90 100644 --- a/.github/workflows/helm-validate.yaml +++ b/.github/workflows/helm-validate.yaml @@ -33,3 +33,6 @@ jobs: - name: Template with example overrides run: helm template openops chart -f chart/values.overrides-example.yaml + + - name: Template with MCP enabled + run: helm template openops chart -f chart/values.mcp-example.yaml diff --git a/AGENTS.md b/AGENTS.md index e93d736..0f543f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,8 @@ - `/chart/values.ci.yaml`: Resource-constrained overlay for CI environments. - `/chart/values.dev.yaml`: Development overlay for local development environments. - `/chart/values.production.yaml`: Production overlay with externalized dependencies and cloud settings. -- `/chart/templates/`: Kubernetes manifests rendered by Helm (43 files). Includes deployments/statefulsets, services, configmaps (`configmap-*.yaml`), secrets (`secret-env.yaml`, `external-secret.yaml`), service accounts, PodDisruptionBudgets, HorizontalPodAutoscalers, NetworkPolicy, LimitRange, ServiceMonitor for Prometheus, and Helm tests. Shared template helpers live in `_helpers.tpl` (561 lines with 49+ helper functions). Postgres and Redis use StatefulSets with volumeClaimTemplates for stable storage and safe rollouts. +- `/chart/values.mcp-example.yaml`: Overlay enabling the opt-in MCP server for external agents; rendered by `helm-validate.yaml` so the enabled path stays valid. +- `/chart/templates/`: Kubernetes manifests rendered by Helm (48 files). Includes deployments/statefulsets, services, configmaps (`configmap-*.yaml`), secrets (`secret-env.yaml`, `external-secret.yaml`), service accounts, PodDisruptionBudgets, HorizontalPodAutoscalers, NetworkPolicy, LimitRange, ServiceMonitor for Prometheus, and Helm tests. Shared template helpers live in `_helpers.tpl` (561 lines with 49+ helper functions). Postgres and Redis use StatefulSets with volumeClaimTemplates for stable storage and safe rollouts. - `/chart/templates/NOTES.txt`: Helm installation notes displayed after deployment with important warnings and next steps. - `/chart/.helmignore`: Excludes development and repository files from packaged charts to reduce size and prevent leaking unnecessary files. - `/LICENSE`: Apache 2.0 license for this Helm chart repository. @@ -24,14 +25,15 @@ ## Production features - **Security-first design**: Security contexts enabled by default (runAsNonRoot, drop ALL capabilities, seccomp RuntimeDefault profile). -- **Service accounts**: Dedicated service accounts for each component (app, engine, tables, analytics, nginx, postgres, redis) with configurable annotations for AWS IAM roles (IRSA), GCP Workload Identity, or Azure Managed Identity. +- **Service accounts**: Dedicated service accounts for each component (app, worker, mcp, tables, analytics, nginx, postgres, redis) with configurable annotations for AWS IAM roles (IRSA), GCP Workload Identity, or Azure Managed Identity. - **External Secrets Operator**: Built-in support for AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, and Azure Key Vault integration. -- **PodDisruptionBudgets (PDBs)**: Configured for `app`, `engine`, `nginx`, `analytics` and `tables` — note that `tables` is stateful (PVC, ReadWriteOnce), while the Postgres and Redis StatefulSets have no PDB. All default to `maxUnavailable: 1` so voluntary disruptions (node drains, upgrades) can always proceed. Each component also accepts `minAvailable` when `maxUnavailable` is unset, but avoid it where a component runs a single replica: `minAvailable: 1` then evaluates to `disruptionsAllowed: 0` and blocks every drain. `analytics` and `tables` default to one replica. -- **HorizontalPodAutoscalers (HPAs)**: Optional autoscaling for app, engine, analytics, and nginx based on CPU/memory metrics. +- **PodDisruptionBudgets (PDBs)**: Configured for `app`, `worker`, `mcp`, `nginx`, `analytics` and `tables` — note that `tables` is stateful (PVC, ReadWriteOnce), while the Postgres and Redis StatefulSets have no PDB. All default to `maxUnavailable: 1` so voluntary disruptions (node drains, upgrades) can always proceed. Each component also accepts `minAvailable` when `maxUnavailable` is unset, but avoid it where a component runs a single replica: `minAvailable: 1` then evaluates to `disruptionsAllowed: 0` and blocks every drain. `analytics` and `tables` default to one replica. +- **HorizontalPodAutoscalers (HPAs)**: Optional autoscaling for app, worker, mcp, analytics, and nginx based on CPU/memory metrics. - **NetworkPolicy**: Optional network segmentation to restrict pod-to-pod communication and enforce least-privilege networking with explicit allow rules. - **LimitRange**: Optional namespace-level resource defaults and constraints to prevent resource exhaustion. - **ServiceMonitor**: Prometheus Operator integration for scraping application metrics from `/metrics` endpoints. - **Helm tests**: Post-installation connectivity tests to validate deployment health. +- **MCP server (opt-in)**: `mcp.enabled` deploys `openops-mcp` behind nginx at `/mcp` and derives the API's OAuth env from `global.publicUrl`. The MCP pod renders only `mcp.env`. `OPS_OAUTH_RS_CLIENT_SECRET` is deliberately not a default `openopsEnvSecrets` key: External Secrets fails the whole sync on a missing property, so the key, `mcp.env` secret collection, nginx routes and NetworkPolicy are all gated on `mcp.enabled`, and `openops.validateMcp` fails the render if the key is undeclared, too short, or `global.publicUrl` is not https. - **Validation helpers**: Runtime validation of required secrets (OPS_ENCRYPTION_KEY, OPS_JWT_SECRET, etc.) with helpful error messages at render time. ## Release workflow diff --git a/README.md b/README.md index d5b856f..e09be38 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenOps Helm Chart -This repository contains the Helm chart that deploys the OpenOps application stack (nginx, app server, engine, tables, analytics, Postgres, Redis) onto a Kubernetes cluster. +This repository contains the Helm chart that deploys the OpenOps application stack (nginx, app server, worker, tables, analytics, Postgres, Redis) onto a Kubernetes cluster. ## Repository layout - `chart/Chart.yaml`: Chart metadata for the `openops` release. @@ -9,7 +9,8 @@ This repository contains the Helm chart that deploys the OpenOps application sta - `chart/values.ci.yaml`: Resource-constrained overlay for CI environments. - `chart/values.dev.yaml`: Development overlay for local development environments. - `chart/values.production.yaml`: Production overlay with externalized dependencies and cloud settings. -- `chart/templates/`: Kubernetes manifests templated by Helm (43 files including deployments, statefulsets, services, configmaps, secrets, external secrets, PVCs, ingress, service accounts, PodDisruptionBudgets, HorizontalPodAutoscalers, NetworkPolicy, LimitRange, ServiceMonitor, Helm tests, and helpers). +- `chart/values.mcp-example.yaml`: Overlay enabling the MCP server for external agents; rendered in CI. +- `chart/templates/`: Kubernetes manifests templated by Helm (48 files including deployments, statefulsets, services, configmaps, secrets, external secrets, PVCs, ingress, service accounts, PodDisruptionBudgets, HorizontalPodAutoscalers, NetworkPolicy, LimitRange, ServiceMonitor, Helm tests, and helpers). - `chart/.helmignore`: Excludes development and repository files from packaged charts. - `LICENSE`: Apache 2.0 license for this Helm chart. - `docs/`: Deployment guides for AWS EKS, EKS Fargate, and other platforms. @@ -17,7 +18,8 @@ This repository contains the Helm chart that deploys the OpenOps application sta ## Components - **nginx**: Reverse proxy and load balancer exposed via `LoadBalancer`. - **openops-app**: Main application server. -- **openops-engine**: Task execution engine. +- **openops-worker**: Workflow execution worker. +- **openops-mcp** (opt-in): MCP server that lets external agents such as Claude Code or Codex operate OpenOps over OAuth. - **openops-tables**: Data tables service (Baserow). - **openops-analytics**: Analytics dashboard (Superset). - **postgres**: PostgreSQL database. @@ -111,12 +113,16 @@ The chart includes several example overlay files to help you get started: **`values.production.yaml`** - Production-ready overlay - Demonstrates externalized PostgreSQL and Redis (AWS RDS, ElastiCache, etc.) -- Increased replica counts for high availability (app: 3, engine: 3, nginx: 2) +- Increased replica counts for high availability (app: 3, worker: 3, nginx: 2) - Production-grade resource allocations (2-4Gi memory per service) - Cloud-specific storage classes (gp3, premium-rwo, managed-csi) - LoadBalancer annotations for AWS/GCP/Azure - Security hardening examples +**`values.mcp-example.yaml`** - MCP server enabled +- Shows the minimum to turn on the MCP server: `mcp.enabled`, an https `global.publicUrl`, and `OPS_OAUTH_RS_CLIENT_SECRET` +- Rendered by the validation workflow so the opt-in path stays green + ### Creating custom overlays **Staging environment example:** @@ -312,6 +318,36 @@ kubectl create secret tls openops-tls \ -n openops ``` +## MCP server for external agents + +External agents such as Claude Code or Codex connect to OpenOps through the `openops-mcp` service, which is disabled by default. When enabled it sits behind nginx at `/mcp` and authenticates agents with OAuth issued by the OpenOps API. The chart derives the API-side settings (`OPS_OAUTH_ENABLED`, `OPS_OAUTH_ISSUER_URL`, `OPS_MCP_RESOURCE_URL`) from `mcp.enabled` and `global.publicUrl`, so the two sides cannot drift. + +Prerequisites: +- `global.publicUrl` must be `https://...` (plain `http` is only accepted for `localhost`), so enable TLS first. +- `OPS_OAUTH_RS_CLIENT_SECRET` must be declared under `openopsEnvSecrets` with a random value of at least 32 characters (`openssl rand -hex 32`). It is shared between the API and the MCP pod. When an external secret manager supplies the value, declare the key as `""`; with `externalSecrets.infraSecretName` set, the chart reads it from the infra secret (the Terraform-generated one on Azure), otherwise from the single remote secret. External Secrets fails the whole sync when a requested property is missing, which is why the chart does not declare this key by default. +- Pull access to the MCP image. Until the first public release the default `mcp.repository` is the private registry `openopsprivate.azurecr.io`; point `mcp.repository`/`mcp.tag` at an image your cluster can pull, or grant the cluster's node identity pull rights on that registry. + +Minimal override: +```yaml +global: + publicUrl: "https://example.openops.com" + +openopsEnvSecrets: + OPS_OAUTH_RS_CLIENT_SECRET: "" + +mcp: + enabled: true + tag: "" +``` + +The MCP pod is stateless and receives only its own environment (`mcp.env`), never the shared `openopsEnv`/`openopsEnvSecrets` layers. It has no health endpoint, so probes use the unauthenticated OAuth metadata route `/.well-known/oauth-protected-resource/mcp`. With `networkPolicy.enabled`, only nginx may reach it and it may only reach the app, DNS, and port 443. + +Connect an agent: +```bash +claude mcp add --transport http openops https://example.openops.com/mcp +``` +The first tool call opens a browser to approve the connection under Settings -> Connected apps. + ## Dependencies The deployments include health checks and readiness probes so dependent services wait until their prerequisites are available. @@ -446,7 +482,7 @@ app: memory: "4Gi" cpu: "2000m" -engine: +worker: replicas: 3 tables: @@ -460,7 +496,7 @@ nginx: ``` **Important scaling considerations:** -- **app** and **engine** are stateless and can be scaled horizontally without restrictions. +- **app** and **worker** are stateless and can be scaled horizontally without restrictions. - **tables** uses file-based storage (SQLite for media) and requires `ReadWriteOnce` PVC; limit to 2-3 replicas or migrate to object storage. - **analytics** can be scaled but shares session state; consider sticky sessions or external session storage for >2 replicas. - **postgres** and **redis** bundled deployments are single-replica; use external managed services for HA. @@ -481,14 +517,14 @@ app: **Resource tuning guidelines:** - **app**: Memory-intensive for large workflows; start with 1-2Gi, scale to 4Gi+ under load. -- **engine**: CPU-intensive for code execution; allocate 500m-1000m CPU per replica. +- **worker**: CPU-intensive for code execution; allocate 500m-1000m CPU per replica. - **tables**: Initial migrations require 1-2Gi memory; steady-state can run on 512Mi-1Gi. - **analytics**: Dashboard rendering is memory-heavy; allocate 2Gi+ for production. - **postgres**: Size based on dataset; 512Mi-1Gi for dev, 2Gi+ for production. - **redis**: Typically light; 256Mi-512Mi sufficient for most workloads. ### Autoscaling -The chart includes optional HorizontalPodAutoscaler (HPA) resources for app, engine, analytics, and nginx. Enable them in your values: +The chart includes optional HorizontalPodAutoscaler (HPA) resources for app, worker, analytics, and nginx. Enable them in your values: ```yaml hpa: @@ -499,7 +535,7 @@ hpa: maxReplicas: 10 targetCPUUtilizationPercentage: 70 targetMemoryUtilizationPercentage: 80 - engine: + worker: enabled: true minReplicas: 2 maxReplicas: 8 @@ -649,7 +685,7 @@ networkPolicy: The default policy enforces: - Nginx accepts traffic from LoadBalancer/Ingress and routes to app/analytics/tables -- App, engine, and analytics can access Postgres and Redis +- App, worker, and analytics can access Postgres and Redis - All components can query DNS and access external HTTPS endpoints - Postgres and Redis only accept connections from authorized components - All other traffic is denied by default (zero-trust networking) @@ -723,10 +759,10 @@ serviceAccount: create: true annotations: eks.amazonaws.com/role-arn: "arn:aws:iam::ACCOUNT:role/openops-app" - engine: + worker: create: true annotations: - eks.amazonaws.com/role-arn: "arn:aws:iam::ACCOUNT:role/openops-engine" + eks.amazonaws.com/role-arn: "arn:aws:iam::ACCOUNT:role/openops-worker" # For GCP Workload Identity analytics: create: true @@ -792,7 +828,7 @@ app: memory: "2Gi" cpu: "1000m" -engine: +worker: replicas: 3 tables: @@ -819,7 +855,7 @@ app: podDisruptionBudget: enabled: true maxUnavailable: 1 -engine: +worker: podDisruptionBudget: enabled: true maxUnavailable: 1 @@ -877,7 +913,7 @@ serviceMonitor: The ServiceMonitor automatically discovers and scrapes metrics from: - `openops-app` on `/metrics` -- `openops-engine` on `/metrics` +- `openops-worker` on `/metrics` - `openops-analytics` on `/metrics` - `postgres` on `:9187/metrics` (if postgres-exporter sidecar is enabled) - `redis` on `:9121/metrics` (if redis-exporter sidecar is enabled) diff --git a/chart/.gitignore b/chart/.gitignore index 11b5665..7895c09 100644 --- a/chart/.gitignore +++ b/chart/.gitignore @@ -2,3 +2,4 @@ values.*.yaml !values.overrides-example.yaml !values.ci.yaml !values.production.yaml +!values.mcp-example.yaml diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 736f396..88f0933 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -94,6 +94,14 @@ To get started: Email: {{ .Values.openopsEnv.OPS_OPENOPS_ADMIN_EMAIL }} Password: (from your secure values file) +{{- if .Values.mcp.enabled }} + +4. Connect an external agent to the MCP server: + + claude mcp add --transport http openops {{ include "openops.publicUrl" . }}/mcp + + The first tool call opens a browser to approve the connection under Settings -> Connected apps. +{{- end }} ================================================================================ DOCUMENTATION & SUPPORT diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index 1c62de6..94b9366 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -156,6 +156,10 @@ Service URLs {{- printf "http://%s:8088" .Values.analytics.name -}} {{- end }} +{{- define "openops.mcpServiceUrl" -}} +{{- printf "http://%s:%v" .Values.mcp.name .Values.mcp.port -}} +{{- end }} + {{/* Check if nginx ingress controller is being used */}} @@ -265,7 +269,9 @@ Expected dict: { "root": $, "env": dict, "secretName": "my-secret" } {{- $infraSecretName = $root.Values.externalSecrets.infraSecretName -}} {{- $appSecretName = $root.Values.externalSecrets.appSecretName -}} {{- end -}} -{{- $infraKeys := list "OPS_POSTGRES_PASSWORD" "OPS_REDIS_URL" "OPS_REDIS_HOST" "OPS_REDIS_PASSWORD" -}} +{{- /* Keys Terraform writes into the infra secret; everything else comes from the app secret. + OPS_OAUTH_RS_CLIENT_SECRET is generated per environment by infra (devops) for the MCP server. */ -}} +{{- $infraKeys := list "OPS_POSTGRES_PASSWORD" "OPS_REDIS_URL" "OPS_REDIS_HOST" "OPS_REDIS_PASSWORD" "OPS_OAUTH_RS_CLIENT_SECRET" -}} {{- range $k := keys $env | sortAlpha -}} {{- $v := index $env $k -}} {{- if eq (include "openops.isSecretKey" (dict "root" $root "key" $k "value" ($v | toString))) "true" }} @@ -413,6 +419,28 @@ Validate that required secrets are configured - ALWAYS ENFORCED {{- fail "ERROR: ANALYTICS_POWERUSER_PASSWORD is required. Use a strong password" -}} {{- end -}} {{- end -}} +{{- include "openops.validateMcp" . -}} +{{- end }} + +{{/* +Validate the MCP / OAuth configuration when the MCP server is enabled. +The key must be declared even when an external secret manager supplies the value, because the +ExternalSecret and the pods' secretKeyRefs are generated from the declared keys. +*/}} +{{- define "openops.validateMcp" -}} +{{- if .Values.mcp.enabled -}} +{{- if not (hasKey .Values.openopsEnvSecrets "OPS_OAUTH_RS_CLIENT_SECRET") -}} +{{- fail "ERROR: mcp.enabled requires openopsEnvSecrets.OPS_OAUTH_RS_CLIENT_SECRET to be declared (openssl rand -hex 32). Declare it as \"\" when the value comes from an external secret manager." -}} +{{- end -}} +{{- $usingExistingSecret := and .Values.secretEnv .Values.secretEnv.existingSecret (not .Values.secretEnv.create) -}} +{{- if and (not $usingExistingSecret) (lt (len .Values.openopsEnvSecrets.OPS_OAUTH_RS_CLIENT_SECRET) 32) -}} +{{- fail "ERROR: OPS_OAUTH_RS_CLIENT_SECRET must be at least 32 characters when mcp.enabled is true. Generate with: openssl rand -hex 32" -}} +{{- end -}} +{{- $url := include "openops.publicUrl" . -}} +{{- if and (not (hasPrefix "https://" $url)) (not (hasPrefix "http://localhost" $url)) -}} +{{- fail (printf "ERROR: mcp.enabled requires an https global.publicUrl (got %s). The MCP server refuses a plain-http OAuth issuer except on localhost." $url) -}} +{{- end -}} +{{- end -}} {{- end }} {{/* diff --git a/chart/templates/configmap-nginx.yaml b/chart/templates/configmap-nginx.yaml index e859abc..478b5f6 100644 --- a/chart/templates/configmap-nginx.yaml +++ b/chart/templates/configmap-nginx.yaml @@ -70,6 +70,54 @@ data: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + {{- if .Values.mcp.enabled }} + + # MCP server for external agents. The /mcp prefix must not be stripped: the MCP server + # derives its OAuth resource identifier from it. + location /mcp { + proxy_pass {{ include "openops.mcpServiceUrl" . }}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # --- MCP streaming-specific settings --- + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + proxy_read_timeout 300s; # long-running tool calls + } + + # OAuth protected-resource metadata (RFC 9728) served by the MCP server at the origin root. + location /.well-known/oauth-protected-resource { + proxy_pass {{ include "openops.mcpServiceUrl" . }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # OAuth authorization-server discovery for the API issuer /api. Without these + # the catch-all serves the frontend's index.html. The API registers both the bare path and + # the "/api"-suffixed variant, and a prefix proxy_pass keeps the suffix, so both resolve. + location /.well-known/oauth-authorization-server { + proxy_pass {{ include "openops.appServiceUrl" . }}/api/.well-known/oauth-authorization-server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /.well-known/openid-configuration { + proxy_pass {{ include "openops.appServiceUrl" . }}/api/.well-known/openid-configuration; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + {{- end }} + location /openops-analytics { proxy_pass {{ include "openops.analyticsServiceUrl" . }}; proxy_set_header Host $host; diff --git a/chart/templates/deployment-mcp.yaml b/chart/templates/deployment-mcp.yaml new file mode 100644 index 0000000..ef3a220 --- /dev/null +++ b/chart/templates/deployment-mcp.yaml @@ -0,0 +1,82 @@ +{{- if .Values.mcp.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.mcp.name }} + labels: + {{- include "openops.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }} + {{- with .Values.global.commonLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.global.commonAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not .Values.mcp.autoscaling.enabled }} + replicas: {{ .Values.mcp.replicas }} + {{- end }} + {{- include "openops.deploymentStrategy" . | nindent 2 }} + selector: + matchLabels: + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "mcp") | nindent 6 }} + template: + metadata: + labels: + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "mcp") | nindent 8 }} + {{- with .Values.global.commonLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + {{- include "openops.prometheusAnnotations" (dict "root" . "component" "mcp") | nindent 8 }} + {{- with include "openops.secretChecksum" . }} + checksum/secret-env: {{ . }} + {{- end }} + {{- with .Values.global.commonAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "openops.serviceAccountName" (dict "root" . "component" "mcp") }} + {{- include "openops.imagePullSecrets" . | nindent 6 }} + {{- include "openops.podSecurityContext" (dict "root" . "component" "mcp") | nindent 6 }} + {{- include "openops.priorityClassName" . | nindent 6 }} + {{- include "openops.terminationGracePeriodSeconds" . | nindent 6 }} + {{- include "openops.topologySpreadConstraints" (dict "root" . "component" "mcp") | nindent 6 }} + {{- include "openops.affinity" (dict "root" . "component" "mcp") | nindent 6 }} + {{- include "openops.nodeSelector" . | nindent 6 }} + {{- include "openops.tolerations" . | nindent 6 }} + {{- include "openops.initContainers" (dict "root" . "component" "mcp") | nindent 6 }} + containers: + - name: {{ .Values.mcp.name }} + image: "{{ include "openops.imageRepository" (dict "root" . "component" "mcp") }}/{{ .Values.mcp.image }}:{{ .Values.mcp.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- include "openops.containerSecurityContext" (dict "root" .) | nindent 10 }} + # Only the MCP-specific env is rendered here. The shared openopsEnv/openopsEnvSecrets + # layers carry database and admin credentials this pod must never receive. + env: + {{- include "openops.renderEnv" (dict "root" . "env" .Values.mcp.env) | trim | nindent 12 }} + ports: + - containerPort: {{ .Values.mcp.port }} + name: http +{{- with .Values.mcp.resources }} + resources: +{{ toYaml . | nindent 12 }} +{{- end }} + # The server exposes no health route. The OAuth protected-resource metadata answers + # 200 without a token, and the process only listens once its startup fetch of the + # OpenAPI document from the API has succeeded, so this doubles as a readiness signal. + {{- include "openops.startupProbe" (dict "path" "/.well-known/oauth-protected-resource/mcp" "port" .Values.mcp.port) | nindent 10 }} + readinessProbe: + httpGet: + path: /.well-known/oauth-protected-resource/mcp + port: {{ .Values.mcp.port }} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /.well-known/oauth-protected-resource/mcp + port: {{ .Values.mcp.port }} + initialDelaySeconds: 30 + periodSeconds: 30 + {{- include "openops.lifecyclePreStop" . | nindent 10 }} +{{- end }} diff --git a/chart/templates/external-secret.yaml b/chart/templates/external-secret.yaml index 2030451..e55f1f7 100644 --- a/chart/templates/external-secret.yaml +++ b/chart/templates/external-secret.yaml @@ -74,5 +74,9 @@ spec: {{- if .Values.worker }}{{- if .Values.worker.env }} {{- range $k, $v := .Values.worker.env }}{{ if not (hasKey $allEnv $k) }}{{ $_ := set $allEnv $k ($v | toString) }}{{ end }}{{ end }} {{- end }}{{- end }} +{{- /* Gated on mcp.enabled: External Secrets fails the whole sync on a missing property */ -}} +{{- if .Values.mcp.enabled }} +{{- range $k, $v := .Values.mcp.env }}{{ if not (hasKey $allEnv $k) }}{{ $_ := set $allEnv $k ($v | toString) }}{{ end }}{{ end }} +{{- end }} {{- include "openops.collectSecretEntries" (dict "root" $ "env" $allEnv "secretName" .Values.externalSecrets.secretName) }} {{- end }} diff --git a/chart/templates/hpa-mcp.yaml b/chart/templates/hpa-mcp.yaml new file mode 100644 index 0000000..496507d --- /dev/null +++ b/chart/templates/hpa-mcp.yaml @@ -0,0 +1,35 @@ +{{- if and .Values.mcp.enabled .Values.mcp.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ .Values.mcp.name }}-hpa + labels: + {{- include "openops.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }} + {{- with .Values.global.commonLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ .Values.mcp.name }} + minReplicas: {{ .Values.mcp.autoscaling.minReplicas }} + maxReplicas: {{ .Values.mcp.autoscaling.maxReplicas }} + metrics: + {{- if .Values.mcp.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.mcp.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.mcp.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.mcp.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/chart/templates/networkpolicy.yaml b/chart/templates/networkpolicy.yaml index 6a4eccf..5f23b64 100644 --- a/chart/templates/networkpolicy.yaml +++ b/chart/templates/networkpolicy.yaml @@ -273,6 +273,55 @@ spec: ports: - protocol: TCP port: 8088 + {{- if .Values.mcp.enabled }} + - to: + - podSelector: + matchLabels: + app.kubernetes.io/component: mcp + ports: + - protocol: TCP + port: {{ .Values.mcp.port }} + {{- end }} + - to: + - namespaceSelector: {} + ports: + - protocol: TCP + port: 53 + - protocol: UDP + port: 53 +{{- if .Values.mcp.enabled }} +--- +# Network policy for mcp: reachable only from nginx; talks to the app (API + OAuth token +# exchange), DNS, and 443 for optional log shipping. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Values.mcp.name }}-netpol + labels: + {{- include "openops.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "mcp") | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: nginx + ports: + - protocol: TCP + port: {{ .Values.mcp.port }} + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/component: app + ports: + - protocol: TCP + port: 80 - to: - namespaceSelector: {} ports: @@ -280,6 +329,12 @@ spec: port: 53 - protocol: UDP port: 53 + - to: + - namespaceSelector: {} + ports: + - protocol: TCP + port: 443 +{{- end }} --- # Network policy for postgres apiVersion: networking.k8s.io/v1 diff --git a/chart/templates/pdb-mcp.yaml b/chart/templates/pdb-mcp.yaml new file mode 100644 index 0000000..359de5a --- /dev/null +++ b/chart/templates/pdb-mcp.yaml @@ -0,0 +1,20 @@ +{{- if and .Values.mcp.enabled .Values.mcp.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ .Values.mcp.name }}-pdb + labels: + {{- include "openops.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }} + {{- with .Values.global.commonLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if not (kindIs "invalid" .Values.mcp.podDisruptionBudget.maxUnavailable) }} + maxUnavailable: {{ .Values.mcp.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.mcp.podDisruptionBudget.minAvailable | default 1 }} + {{- end }} + selector: + matchLabels: + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "mcp") | nindent 6 }} +{{- end }} diff --git a/chart/templates/secret-env.yaml b/chart/templates/secret-env.yaml index 926b7a2..f6eefb2 100644 --- a/chart/templates/secret-env.yaml +++ b/chart/templates/secret-env.yaml @@ -11,6 +11,9 @@ {{- if and $create (not $existingSecret) -}} {{- $autoSecretData := dict -}} {{- $envSources := list .Values.openopsEnvSecrets .Values.openopsEnv .Values.worker.env .Values.tables.env .Values.analytics.env .Values.postgres.env -}} +{{- if .Values.mcp.enabled }} +{{- $envSources = append $envSources .Values.mcp.env -}} +{{- end }} {{- range $env := $envSources }} {{- if $env }} {{- range $k, $v := $env }} diff --git a/chart/templates/service-mcp.yaml b/chart/templates/service-mcp.yaml new file mode 100644 index 0000000..e39cec9 --- /dev/null +++ b/chart/templates/service-mcp.yaml @@ -0,0 +1,17 @@ +{{- if .Values.mcp.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.mcp.name }} + labels: + {{- include "openops.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: {{ .Values.mcp.port }} + targetPort: {{ .Values.mcp.port }} + protocol: TCP + name: http + selector: + {{- include "openops.componentSelectorLabels" (dict "root" . "component" "mcp") | nindent 4 }} +{{- end }} diff --git a/chart/templates/serviceaccount-mcp.yaml b/chart/templates/serviceaccount-mcp.yaml new file mode 100644 index 0000000..b7a1343 --- /dev/null +++ b/chart/templates/serviceaccount-mcp.yaml @@ -0,0 +1,15 @@ +{{- if and .Values.mcp.enabled .Values.mcp.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "openops.serviceAccountName" (dict "root" . "component" "mcp") }} + labels: + {{- include "openops.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }} + {{- with .Values.global.commonLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.mcp.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/chart/values.ci.yaml b/chart/values.ci.yaml index 3f82918..e5a40e2 100644 --- a/chart/values.ci.yaml +++ b/chart/values.ci.yaml @@ -53,6 +53,21 @@ worker: cpu: "300m" memory: "768Mi" +# MCP stays disabled in CI: the default image is in a private registry the kind cluster +# cannot pull from. Rendering with MCP enabled is covered by values.mcp-example.yaml. +mcp: + enabled: false + replicas: 1 + podDisruptionBudget: + enabled: false + resources: + requests: + cpu: "50m" + memory: "128Mi" + limits: + cpu: "200m" + memory: "256Mi" + analytics: replicas: 1 podDisruptionBudget: diff --git a/chart/values.mcp-example.yaml b/chart/values.mcp-example.yaml new file mode 100644 index 0000000..07f6922 --- /dev/null +++ b/chart/values.mcp-example.yaml @@ -0,0 +1,28 @@ +# Example overlay enabling the MCP server for external agents. +# Used by CI to validate rendering with MCP on; copy the relevant keys into your own overrides. +# Make sure to regenerate passwords and secrets. + +global: + publicUrl: "https://openops.example.com" # must be https (or http://localhost) + +openopsEnv: + OPS_OPENOPS_ADMIN_EMAIL: openops@example.com + +openopsEnvSecrets: + OPS_OPENOPS_ADMIN_PASSWORD: 9wqhK7jehmxAQlre + OPS_ENCRYPTION_KEY: 7de996f020438444dc2c79e9b843805d + OPS_JWT_SECRET: M72yCOp9riLjIDws + OPS_POSTGRES_PASSWORD: WZKUM0tSvFC6VWai + OPS_ANALYTICS_ADMIN_PASSWORD: 7YO7YEVRaECPQimI + ANALYTICS_POWERUSER_PASSWORD: v5vIvwhdBDWwx4el + # Shared between the API (resource-server credentials) and the MCP pod. openssl rand -hex 32 + OPS_OAUTH_RS_CLIENT_SECRET: 4c1f0f6a2c3b4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6 + +mcp: + enabled: true + +ingress: + enabled: true + ingressClassName: nginx + tlsConfig: + enabled: true diff --git a/chart/values.schema.json b/chart/values.schema.json index 0a1df5a..6531304 100644 --- a/chart/values.schema.json +++ b/chart/values.schema.json @@ -75,6 +75,39 @@ } } }, + "mcp": { + "type": "object", + "required": ["enabled", "name", "image", "tag", "port", "replicas"], + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "repository": { + "type": "string", + "description": "Override global image.repository for mcp (empty = use global)" + }, + "tag": { + "type": "string", + "minLength": 1, + "description": "MCP image tag; versioned independently of global.version" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "replicas": { + "type": "integer", + "minimum": 0 + } + } + }, "tables": { "type": "object", "required": ["name", "image", "tag", "replicas"], diff --git a/chart/values.yaml b/chart/values.yaml index a2a70d5..67366ff 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -110,6 +110,12 @@ openopsEnv: OPS_WORKER_URL: '{{ include "openops.workerServiceUrl" . }}' OPS_EXECUTION_MODE: SANDBOX_CODE_ONLY + # OAuth for external agents (MCP). Derived from mcp.enabled and global.publicUrl so the API + # and the MCP pod always agree on issuer and resource. See the "mcp" section below. + OPS_OAUTH_ENABLED: '{{ .Values.mcp.enabled }}' + OPS_OAUTH_ISSUER_URL: '{{ include "openops.publicUrl" . }}/api' + OPS_MCP_RESOURCE_URL: '{{ include "openops.publicUrl" . }}/mcp' + # Queue OPS_QUEUE_MODE: REDIS OPS_REDIS_URL: '{{ include "openops.redisUrl" . }}' @@ -167,6 +173,10 @@ openopsEnvSecrets: OPS_LANGFUSE_PUBLIC_KEY: "" OPS_LANGFUSE_SECRET_KEY: "" OPS_SSO_FRONTEGG_PUBLIC_KEY: "" + # When mcp.enabled is true, declare OPS_OAUTH_RS_CLIENT_SECRET here (openssl rand -hex 32). + # It is intentionally not a default: External Secrets fails the whole sync when a requested + # property is missing, so adding it unconditionally would break installs that keep MCP off. + # OPS_OAUTH_RS_CLIENT_SECRET: "" secretEnv: create: true @@ -253,6 +263,62 @@ worker: OPS_BASE_CODE_DIRECTORY: /tmp/codes OPS_SERVER_API_URL: '{{ printf "%s/api/" (include "openops.appServiceUrl" .) }}' +# MCP server for external agents (Claude Code, Codex, ...). Opt-in: serves /mcp +# through nginx and authenticates agents with OAuth issued by the OpenOps API. Enabling it +# requires an https global.publicUrl (or localhost) and OPS_OAUTH_RS_CLIENT_SECRET in +# openopsEnvSecrets (at least 32 characters, shared with the API). +mcp: + enabled: false + name: openops-mcp + image: openops-mcp + # Main-branch builds live in the private registry until the first public release; the + # public image will be openops.azurecr.io/openops-mcp:. Pulling from the private + # registry needs pull access on the cluster (imagePullSecrets or a registry role). + repository: "openopsprivate.azurecr.io" + tag: "8e7c930f" # Independently versioned (8-char commit sha or release version), not global.version + port: 3020 + replicas: 2 # Stateless; any replica serves any request + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + # Pod Disruption Budget - ENABLED BY DEFAULT + podDisruptionBudget: + enabled: true + maxUnavailable: 1 + # Horizontal Pod Autoscaler + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + # Init containers (optional) + initContainers: [] + # Service Account + serviceAccount: + create: true + name: "" + annotations: {} + # No "metrics" map: the server exposes no /metrics endpoint. Add one (enabled/port/path) + # and a ServiceMonitor block once it does; the Prometheus annotations helper skips + # components without it. + # Only these variables reach the MCP pod; openopsEnv/openopsEnvSecrets are not injected. + # Values referencing .Values.openopsEnvSecrets.* are sourced from the shared Secret. + env: + MCP_HTTP_PORT: '{{ .Values.mcp.port }}' + OPENOPS_API_URL: '{{ include "openops.appServiceUrl" . }}/api' + OPENOPS_MCP_PROFILE: agent + OPENOPS_MCP_ISSUER: '{{ .Values.openopsEnv.OPS_OAUTH_ISSUER_URL }}' + OPENOPS_MCP_RESOURCE_URL: '{{ .Values.openopsEnv.OPS_MCP_RESOURCE_URL }}' + OPENOPS_MCP_CLIENT_SECRET: '{{ .Values.openopsEnvSecrets.OPS_OAUTH_RS_CLIENT_SECRET }}' + LOG_LEVEL: '{{ .Values.openopsEnv.OPS_LOG_LEVEL }}' + ENVIRONMENT: '{{ .Values.openopsEnv.OPS_ENVIRONMENT_NAME }}' + LOGZIO_TOKEN: '{{ .Values.openopsEnvSecrets.OPS_LOGZIO_TOKEN }}' + tables: name: openops-tables image: openops-tables From df841b2ea9c36b5cb705f220147c40dcb3c5e982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 16 Sep 2026 12:42:27 +0100 Subject: [PATCH 7/8] Add optional AWS web identity token for the worker (#39) Part of OPS-4267 --- chart/templates/deployment-worker.yaml | 18 ++++++++++++++++++ chart/values.yaml | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/chart/templates/deployment-worker.yaml b/chart/templates/deployment-worker.yaml index a4c46e9..ab5947b 100644 --- a/chart/templates/deployment-worker.yaml +++ b/chart/templates/deployment-worker.yaml @@ -55,6 +55,10 @@ spec: value: worker - name: OPS_CONTAINER_TYPE value: WORKER + {{- if .Values.worker.awsWebIdentity.enabled }} + - name: OPS_AWS_WEB_IDENTITY_TOKEN_FILE + value: {{ printf "%s/token" (trimSuffix "/" .Values.worker.awsWebIdentity.mountPath) | quote }} + {{- end }} {{ include "openops.renderEnv" (dict "root" . "env" .Values.openopsEnv "skipDuplicateSecrets" true) | nindent 12 }} {{ include "openops.renderEnv" (dict "root" . "env" .Values.openopsEnvSecrets) | nindent 12 }} {{ include "openops.renderEnv" (dict "root" . "env" .Values.worker.env) | nindent 12 }} @@ -71,8 +75,22 @@ spec: mountPath: /tmp/.npm - name: tmp-codes mountPath: /tmp/codes + {{- if .Values.worker.awsWebIdentity.enabled }} + - name: aws-web-identity-token + mountPath: {{ .Values.worker.awsWebIdentity.mountPath }} + readOnly: true + {{- end }} volumes: - name: npm-cache emptyDir: {} - name: tmp-codes emptyDir: {} + {{- if .Values.worker.awsWebIdentity.enabled }} + - name: aws-web-identity-token + projected: + sources: + - serviceAccountToken: + path: token + audience: {{ .Values.worker.awsWebIdentity.audience | quote }} + expirationSeconds: {{ .Values.worker.awsWebIdentity.expirationSeconds }} + {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 67366ff..de5963b 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -254,6 +254,16 @@ worker: create: true name: "" annotations: {} + # Exchange a projected service account token for AWS credentials, the way IRSA + # does on EKS. Enabling this projects the token and sets + # OPS_AWS_WEB_IDENTITY_TOKEN_FILE to match; you supply the role it federates + # into as OPS_AWS_FEDERATION_ROLE_ARN in openopsEnv. + # Leave disabled on EKS: IRSA injects its own token and the AWS SDK finds it. + awsWebIdentity: + enabled: false + audience: sts.amazonaws.com + expirationSeconds: 3600 + mountPath: /var/run/secrets/aws # Prometheus metrics metrics: enabled: true From 2a374eb54a3b204ffe1e19321cf38d8342528336 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Vallamkonda <36404296+ravikiranvm@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:57:45 +0530 Subject: [PATCH 8/8] Pin the MCP image to the public 0.2.1 release (#40) Co-authored-by: Claude Fable 5.1 --- README.md | 2 +- chart/values.yaml | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e09be38..c8da9f7 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ External agents such as Claude Code or Codex connect to OpenOps through the `ope Prerequisites: - `global.publicUrl` must be `https://...` (plain `http` is only accepted for `localhost`), so enable TLS first. - `OPS_OAUTH_RS_CLIENT_SECRET` must be declared under `openopsEnvSecrets` with a random value of at least 32 characters (`openssl rand -hex 32`). It is shared between the API and the MCP pod. When an external secret manager supplies the value, declare the key as `""`; with `externalSecrets.infraSecretName` set, the chart reads it from the infra secret (the Terraform-generated one on Azure), otherwise from the single remote secret. External Secrets fails the whole sync when a requested property is missing, which is why the chart does not declare this key by default. -- Pull access to the MCP image. Until the first public release the default `mcp.repository` is the private registry `openopsprivate.azurecr.io`; point `mcp.repository`/`mcp.tag` at an image your cluster can pull, or grant the cluster's node identity pull rights on that registry. +- The MCP image is pulled from the public registry (`openops.azurecr.io/openops-mcp`, pinned by `mcp.tag`), like Tables and Analytics; no pull credentials are needed. Point `mcp.repository`/`mcp.tag` elsewhere to run a different build. Minimal override: ```yaml diff --git a/chart/values.yaml b/chart/values.yaml index de5963b..76ef9f1 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -281,11 +281,8 @@ mcp: enabled: false name: openops-mcp image: openops-mcp - # Main-branch builds live in the private registry until the first public release; the - # public image will be openops.azurecr.io/openops-mcp:. Pulling from the private - # registry needs pull access on the cluster (imagePullSecrets or a registry role). - repository: "openopsprivate.azurecr.io" - tag: "8e7c930f" # Independently versioned (8-char commit sha or release version), not global.version + repository: "openops.azurecr.io" # Always the public registry for mcp + tag: "0.2.1" # Independently versioned (openops-mcp release), not global.version port: 3020 replicas: 2 # Stateless; any replica serves any request resources: