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..0e1dd90 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 @@ -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/.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..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,32 +25,33 @@ ## 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 -- **`.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..c8da9f7 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. @@ -28,7 +30,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 @@ -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,40 +318,55 @@ kubectl create secret tls openops-tls \ -n openops ``` -## Dependencies -The deployments include health checks and readiness probes so dependent services wait until their prerequisites are available. +## MCP server for external agents -## Private ECR access from non-AWS clusters +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. -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: +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. +- 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 global: - imagePullSecrets: - - ecr-pull-secret + publicUrl: "https://example.openops.com" + +openopsEnvSecrets: + OPS_OAUTH_RS_CLIENT_SECRET: "" -ecrCredentialRefresh: +mcp: enabled: true - registry: "" - awsRegion: "us-east-2" - awsSecretName: "ecr-credentials" # K8s secret with AWS access keys - imagePullSecretName: "ecr-pull-secret" # Created/refreshed automatically + tag: "" ``` -**Pre-requisite:** Create the AWS credentials secret (one-time): +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 -kubectl create secret generic ecr-credentials \ - -n openops \ - --from-literal=AWS_ACCESS_KEY_ID= \ - --from-literal=AWS_SECRET_ACCESS_KEY= +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. -The IAM user needs only `ecr:GetAuthorizationToken`, `ecr:BatchGetImage`, and `ecr:GetDownloadUrlForLayer` permissions. +## Pulling from a private registry -**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` +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: + - my-registry-credentials + +image: + repository: my-registry.example.com/openops +``` ## Analytics (Superset) configuration override @@ -461,7 +482,7 @@ app: memory: "4Gi" cpu: "2000m" -engine: +worker: replicas: 3 tables: @@ -475,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. @@ -496,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: @@ -514,7 +535,7 @@ hpa: maxReplicas: 10 targetCPUUtilizationPercentage: 70 targetMemoryUtilizationPercentage: 80 - engine: + worker: enabled: true minReplicas: 2 maxReplicas: 8 @@ -664,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) @@ -738,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 @@ -807,7 +828,7 @@ app: memory: "2Gi" cpu: "1000m" -engine: +worker: replicas: 3 tables: @@ -834,7 +855,7 @@ app: podDisruptionBudget: enabled: true maxUnavailable: 1 -engine: +worker: podDisruptionBudget: enabled: true maxUnavailable: 1 @@ -892,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 03312f6..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 @@ -118,7 +126,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/_helpers.tpl b/chart/templates/_helpers.tpl index abf6c4b..94b9366 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" -}} @@ -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/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/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-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/deployment-engine.yaml b/chart/templates/deployment-worker.yaml similarity index 61% rename from chart/templates/deployment-engine.yaml rename to chart/templates/deployment-worker.yaml index 6b567c6..ab5947b 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,37 @@ 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 + {{- 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.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 }} @@ -72,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/templates/external-secret.yaml b/chart/templates/external-secret.yaml index 6d74d71..e55f1f7 100644 --- a/chart/templates/external-secret.yaml +++ b/chart/templates/external-secret.yaml @@ -71,8 +71,12 @@ 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 }} +{{- /* 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/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..5f23b64 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: @@ -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 @@ -306,7 +361,7 @@ spec: - from: - podSelector: matchLabels: - app.kubernetes.io/component: engine + app.kubernetes.io/component: worker ports: - protocol: TCP port: 5432 @@ -358,7 +413,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-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/pdb-engine.yaml b/chart/templates/pdb-worker.yaml similarity index 53% rename from chart/templates/pdb-engine.yaml rename to chart/templates/pdb-worker.yaml index e3a2829..60d023e 100644 --- a/chart/templates/pdb-engine.yaml +++ b/chart/templates/pdb-worker.yaml @@ -1,20 +1,20 @@ -{{- 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: - {{- 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: - {{- 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..f6eefb2 100644 --- a/chart/templates/secret-env.yaml +++ b/chart/templates/secret-env.yaml @@ -10,7 +10,10 @@ {{- $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 -}} +{{- 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/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-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/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..e5a40e2 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 @@ -41,7 +41,7 @@ app: cpu: "500m" memory: "1Gi" -engine: +worker: replicas: 1 podDisruptionBudget: enabled: false @@ -53,6 +53,21 @@ engine: 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.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..6531304 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,40 @@ }, "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", + "minimum": 0 + } + } + }, + "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", @@ -87,7 +120,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 +145,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 ef6990a..76ef9f1 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: @@ -106,10 +106,16 @@ 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 + # 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" . }}' @@ -149,7 +155,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). @@ -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 @@ -214,9 +224,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: @@ -244,6 +254,16 @@ engine: 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 @@ -253,11 +273,64 @@ engine: 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 + 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: + 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 - 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 +392,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: @@ -598,7 +671,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 @@ -654,23 +727,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