A Kubernetes Operator that deploys and manages the complete OpenShift Pulse stack — AI SRE agent, React UI, PostgreSQL — from a single custom resource on OpenShift.
oc apply -f pulse.yaml → Running cluster AI assistant in ~5 minutes
- What it does
- Prerequisites
- Install via OLM ← recommended
- Install via manifest
- Create your first Pulse instance
- CR Spec reference
- CR Status reference
- Upgrading
- Uninstall
- Development
- OLM bundle / OperatorHub
- Architecture
- Troubleshooting
- Security
- Contributing
- License
One OpenShiftPulse CR drives the full lifecycle:
| Reconciler | Resources managed |
|---|---|
| Agent | ClusterRole (read-only cluster access), WS token Secret, memory PVC, Deployment, Service |
| PostgreSQL | StatefulSet (pg-data PVC retained on delete), pg-auth Secret, ClusterIP + headless Services |
| UI | nginx ConfigMap, oauth-proxy Deployment (TLS on 8443), Service, Route, OAuthClient |
| Monitoring | ServiceMonitor (agent /metrics), PrometheusRule (PulseAgentDown, PulsePostgreSQLDown) |
| MCP | MCP server Deployment + Service (optional, spec.agent.mcp.enabled: true) |
| Network | UI ingress NetworkPolicy (OCP ingress + Prometheus only), PostgreSQL access-only NetworkPolicy |
| Cluster detect | Reads ingress domain, oauth-proxy image digest, ACM availability on first reconcile |
A pulse.ai/cleanup finalizer ensures ClusterRoles and OAuthClient are removed when the CR is deleted — no orphans on uninstall.
- OpenShift 4.12+ (uses Route, OAuthClient, ImageStream APIs)
ocCLI withcluster-admin- Prometheus Operator (for
ServiceMonitor/PrometheusRule— ships with OpenShift Monitoring) - One of: Vertex AI GCP service account key, or Anthropic API key
This is the recommended path. It installs the operator through OLM so it appears in the OpenShift Installed Operators view and receives automatic upgrades.
cat <<EOF | oc apply -f -
apiVersion: operators.coreos.com/v1alpha1
kind: CatalogSource
metadata:
name: pulse-operator-catalog
namespace: openshift-marketplace
spec:
sourceType: grpc
image: quay.io/amobrem/pulse-operator-catalog:latest
displayName: Pulse Operator
publisher: Red Hat CoE
updateStrategy:
registryPoll:
interval: 10m
EOFWait for it to become ready:
oc get catalogsource pulse-operator-catalog -n openshift-marketplace -w
# STATE should reach READY within ~30 secondsoc new-project openshiftpulseVertex AI (GCP):
oc create secret generic gcp-sa-key \
--from-file=key.json=/path/to/sa-key.json \
-n openshiftpulseAnthropic API:
oc create secret generic anthropic-api-key \
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
-n openshiftpulsecat <<EOF | oc apply -f -
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
name: pulse-operator-group
namespace: openshiftpulse
spec:
targetNamespaces: []
---
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: pulse-operator
namespace: openshiftpulse
spec:
channel: alpha
name: pulse-operator
source: pulse-operator-catalog
sourceNamespace: openshift-marketplace
installPlanApproval: Automatic
EOFWatch the CSV reach Succeeded:
oc get csv -n openshiftpulse -wThen skip to Create your first Pulse instance.
For environments without OLM or for quick testing:
# Install CRD
oc apply -f https://raw.githubusercontent.com/PulseSRE/pulse-operator/main/config/crd/bases/pulse.ai_openshiftpulses.yaml
# Deploy operator
oc apply -f https://raw.githubusercontent.com/PulseSRE/pulse-operator/main/deploy/operator.yaml
oc rollout status deployment/pulse-operator-manager -n pulse-operator-systemOr clone and use make:
git clone https://github.com/PulseSRE/pulse-operator
cd pulse-operator
make deploycat <<EOF | oc apply -f -
apiVersion: pulse.ai/v1alpha1
kind: OpenShiftPulse
metadata:
name: pulse
namespace: openshiftpulse
spec:
vertexAI:
projectId: my-gcp-project
region: us-east5
credentialSecret: gcp-sa-key
agent:
image: quay.io/amobrem/pulse-agent:latest
trustLevel: 2
mcp:
enabled: true
ui:
image: quay.io/amobrem/openshiftpulse:latest
replicas: 2
database:
storageSize: 5Gi
monitoring:
enabled: true
EOFWatch the stack come up:
oc get openshiftpulse pulse -n openshiftpulse -wNAME PHASE AGENTHEALTHY DBREADY UIAVAILABLE ROUTEHOST
pulse Installing false false false
pulse Installing false true false
pulse Installing true true false
pulse Running true true true pulse-openshiftpulse.apps.cluster.example.com
Open the Route host in a browser — you'll authenticate through OpenShift OAuth and land in the Pulse UI.
oc get route -n openshiftpulse -o jsonpath='{.items[0].spec.host}'spec:
# ── AI Backend ─────────────────────────────────────────────────────────────
# Exactly one of vertexAI or anthropicApiKey is required.
vertexAI:
projectId: my-gcp-project # GCP project ID
region: us-east5 # Vertex AI region
credentialSecret: gcp-sa-key # Secret with key.json
anthropicApiKey:
existingSecret: anthropic-api-key # Secret with ANTHROPIC_API_KEY
# ── Agent ───────────────────────────────────────────────────────────────────
agent:
image: quay.io/amobrem/pulse-agent:latest
trustLevel: 2 # 0=observe · 1=suggest · 2=confirm · 3=batch · 4=autonomous
allowWriteOperations: false # adds delete(pods), patch(deployments) to agent ClusterRole
allowSecretAccess: false # adds get/list/watch(secrets) to agent ClusterRole
resources: {} # corev1.ResourceRequirements
mcp:
enabled: false # deploys MCP server sidecar for tool extension
# ── UI ──────────────────────────────────────────────────────────────────────
ui:
image: quay.io/amobrem/openshiftpulse:latest
replicas: 2
oauthProxyImage: "" # auto-detected from cluster ImageStream when empty
resources: {}
# ── Database ─────────────────────────────────────────────────────────────────
database:
storageSize: 5Gi # PVC size; cannot shrink after creation
storageClass: "" # cluster default if empty
image: "" # RHEL9 postgresql-15 if empty
# ── Monitoring ───────────────────────────────────────────────────────────────
monitoring:
enabled: true # creates ServiceMonitor + PrometheusRule alerts| Level | Behaviour |
|---|---|
0 — observe |
Read-only. Agent answers questions but takes no action. |
1 — suggest |
Proposes actions in the UI, user approves each one. |
2 — confirm |
Default. Agent executes after a single user confirmation. |
3 — batch |
Executes batches of low-risk actions with one confirmation. |
4 — autonomous |
Executes without confirmation. Use with caution. |
status:
phase: Running # Pending | Installing | Running | Degraded
agentHealthy: true # agent Deployment has ≥1 ready replica
databaseReady: true # PostgreSQL StatefulSet has ≥1 ready replica
uiAvailable: true # Route hostname assigned by OCP router
routeHost: pulse-openshiftpulse.apps.cluster.example.com
observedGeneration: 3
conditions:
- type: Ready
status: "True"
reason: AllComponentsReady
observedGeneration: 3OLM handles upgrades automatically when installPlanApproval: Automatic. To upgrade manually:
# Check available versions
oc get packagemanifest pulse-operator -n openshift-marketplace \
-o jsonpath='{range .status.channels[*]}{.name}{": "}{.currentCSV}{"\n"}{end}'
# Approve a pending install plan
oc get installplan -n openshiftpulse
oc patch installplan <name> -n openshiftpulse \
--type=merge -p '{"spec":{"approved":true}}'Update the image tag in the CR — the operator rolls out the new image immediately:
oc patch openshiftpulse pulse -n openshiftpulse --type=merge \
-p '{"spec":{"agent":{"image":"quay.io/amobrem/pulse-agent:v2.0.0"}}}'# Delete CR — operator finalizer cleans up ClusterRoles and OAuthClient
oc delete openshiftpulse pulse -n openshiftpulse
# Remove operator (OLM install)
oc delete subscription pulse-operator -n openshiftpulse
oc delete csv pulse-operator.v0.1.0 -n openshiftpulse
oc delete catalogsource pulse-operator-catalog -n openshift-marketplace
# Or for manifest install
make undeploy
# Remove namespace
oc delete namespace openshiftpulseData retention: PostgreSQL PVCs are retained by the StatefulSet
volumeClaimTemplatelifecycle and survive operator deletion. Re-create the CR to reattach them.
go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
setup-envtest use 1.31 --bin-dir /tmp/kubebuilder-binKUBEBUILDER_ASSETS=/tmp/kubebuilder-bin/k8s/1.31.0-darwin-arm64 make test# Install CRD
oc apply -f config/crd/bases/pulse.ai_openshiftpulses.yaml
# Run operator process (skip leader election)
go run ./cmd/main.go \
--leader-elect=false \
--metrics-bind-address=:9191 \
--health-probe-bind-address=:9292
# Apply a CR in another terminal
oc apply -f examples/pulse.yamlGotchas:
- Cache warmup takes 60–90 s over WAN. The first
Reconciling OpenShiftPulselog line confirms the controller is active. - gp3-csi PVC provisioning takes 2–5 min. The agent Deployment is intentionally gated behind a
BoundPVC check. - Build for
linux/amd64explicitly when on Apple Silicon:make docker-buildpasses--platform linux/amd64automatically.
make docker-build docker-push OPERATOR_IMG=quay.io/yourorg/pulse-operator:devCONTAINER_TOOL defaults to podman. Override with CONTAINER_TOOL=docker if needed.
make manifests generateThe catalog image is a File-Based Catalog (FBC) served by opm. To rebuild it after bundle changes:
# 1. Render the bundle into FBC YAML
podman run --rm -v $(pwd)/bundle:/bundle:z \
quay.io/operator-framework/opm:latest render /bundle -o yaml > /tmp/catalog.yaml
# 2. Prepend package + channel declarations
cat - /tmp/catalog.yaml > /tmp/full-catalog.yaml <<'HEADER'
---
defaultChannel: alpha
name: pulse-operator
schema: olm.package
---
entries:
- name: pulse-operator.v0.1.0
name: alpha
package: pulse-operator
schema: olm.channel
HEADER
# 3. Build catalog image (linux/amd64 — cluster nodes are x86)
podman build --platform linux/amd64 \
-f Dockerfile.catalog \
-t quay.io/amobrem/pulse-operator-catalog:latest .
podman push quay.io/amobrem/pulse-operator-catalog:latestmake bundle-validateTag to trigger CI publishing:
git tag v0.2.0 -m "v0.2.0: ..."
git push origin v0.2.0CI builds and pushes:
quay.io/amobrem/pulse-operator:v0.2.0+:latestquay.io/amobrem/pulse-operator-catalog:v0.2.0+:latest
Requires QUAY_USERNAME and QUAY_TOKEN as GitHub repository secrets.
pulse-operator-system/
└── pulse-operator-manager (Deployment — 1 replica, leader-elected)
└── OpenShiftPulseReconciler
├── pulse.ai/cleanup finalizer ← removes cluster-scoped resources on CR delete
│
├── AgentReconciler
│ ├── {ns}/{name}-openshift-sre-agent (ServiceAccount + ClusterRole + ClusterRoleBinding)
│ ├── {ns}/{name}-ws-token (Secret — random 32-char hex, never rotated)
│ ├── {ns}/{name}-openshift-sre-agent-memory (PVC 1Gi RWO — gated before Deployment)
│ └── {ns}/{name}-openshift-sre-agent (Deployment + Service :8080)
│
├── PostgreSQLReconciler
│ ├── {ns}/{name}-pg-auth (Secret — POSTGRESQL_* RHSCL env vars)
│ ├── {ns}/{name}-openshift-sre-agent-postgresql (StatefulSet + pg-data PVC)
│ └── {ns}/{name}-openshift-sre-agent-postgresql[-headless] (Services)
│
├── UIReconciler
│ ├── {ns}/{name}-openshiftpulse (ServiceAccount + ClusterRole)
│ ├── {ns}/{name}-oauth-secrets (Secret — client-secret + cookie-secret)
│ ├── {ns}/{name}-nginx (ConfigMap — nginx.conf, root /opt/app-root/src)
│ ├── {ns}/{name}-openshiftpulse (Deployment: nginx + oauth-proxy sidecars)
│ ├── {ns}/{name}-openshiftpulse (Service :8443)
│ ├── {ns}/{name}-openshiftpulse (Route — reencrypt, OCP assigns hostname)
│ └── openshiftpulse-{ns}-{name} (OAuthClient — cluster-scoped, redirect URI auto-set)
│
├── MonitoringReconciler [spec.monitoring.enabled]
│ ├── {ns}/{name}-openshift-sre-agent (ServiceMonitor → /metrics)
│ └── {ns}/{name}-openshiftpulse (PrometheusRule — 3 alert rules)
│
├── MCPReconciler [spec.agent.mcp.enabled]
│ └── {ns}/{name}-mcp-server (Deployment + Service :8001)
│
└── NetworkPolicyReconciler
├── {ns}/{name}-openshiftpulse (UI: ingress from OCP router + Prometheus only)
└── {ns}/{name}-pg-access (PG: ingress from agent pods only)
On the first reconcile, ClusterDetector reads:
ingresses.config.openshift.io/cluster→ wildcard app domain for Route hostopenshift/oauth-proxyImageStream → correct proxy image digest for this OCP versionopen-cluster-management-observabilitynamespace → ACM multicluster availability
Results are cached (sync.Once) — zero API calls on subsequent reconciles.
Symptom: The authorization server encountered an unexpected condition
Cause: The ServiceAccount used by oauth-proxy needs an OCP-specific annotation declaring the valid redirect URI.
ROUTE=$(oc get route -n openshiftpulse -o jsonpath='{.items[0].spec.host}')
oc annotate sa pulse-openshiftpulse -n openshiftpulse \
"serviceaccounts.openshift.io/oauth-redirecturi.pulse=https://$ROUTE" \
--overwrite
oc delete pods -n openshiftpulse -l app=pulse-openshiftpulseSymptom: Default nginx welcome page at the route URL.
Cause: Stale ConfigMap or nginx not pointing at /opt/app-root/src. Force reconcile:
oc delete configmap pulse-nginx -n openshiftpulse
# Operator recreates it within secondsSymptom: Agent pod crashes with MCP not ready (attempt N/36)
Cause: MCP server is enabled in spec but its Service doesn't exist yet (first deployment race).
oc get pods -n openshiftpulse
oc logs -n openshiftpulse <agent-pod> -c agent | tail -20
# Wait for mcp-server pod to become Ready, agent restarts automaticallyCause: The PVC may not be bound yet, or a previous Helm-managed StatefulSet had incompatible labels. The operator will detect the mismatch and delete/recreate the StatefulSet automatically. PostgreSQL data on the PVC is preserved.
oc get pvc -n openshiftpulse
oc describe statefulset -n openshiftpulseThe operator cannot adopt Helm-managed Deployments/StatefulSets because their label selectors are immutable. The operator detects selector mismatches and replaces the resources automatically (StatefulSet with orphan cascade to preserve the PVC). If the agent Deployment is stuck:
# Manually delete the Helm-managed deployment; operator recreates it cleanly
oc delete deployment pulse-openshift-sre-agent -n openshiftpulseDespite showing TRANSIENT_FAILURE, if PackageManifest is visible the catalog is functional:
oc get packagemanifest pulse-operator -n openshift-marketplaceIf the pod itself is crashing:
oc logs -n openshift-marketplace -l olm.catalogSource=pulse-operator-catalogCommon causes: wrong image architecture (build with --platform linux/amd64), missing grpc_health_probe binary in the catalog image.
- All managed pods run as non-root with
AllowPrivilegeEscalation=false,Capabilities.Drop=ALL, andSeccompProfile=RuntimeDefault. - PostgreSQL sets
ReadOnlyRootFilesystem=false(PG requires writable socket and temp paths). - The operator's ClusterRole includes
escalate+bindon RBAC resources — required to create ClusterRoles for managed instances, but represents a privilege escalation path. Restrict exec access topulse-operator-systemvia NetworkPolicy. - OAuthClient names are scoped to
{namespace}-{name}to prevent collision when multiple CRs coexist on the same cluster. - Secrets (pg-auth, ws-token, oauth cookie) are generated once and never rotated automatically. Rotate manually by deleting the Secret — the operator regenerates it on next reconcile.
Pull requests are welcome. For substantial changes, open an issue first to discuss the approach.
# Fork and clone
git clone https://github.com/YOUR_USERNAME/pulse-operator
cd pulse-operator
# Create a feature branch
git checkout -b feat/my-change
# Make changes, run tests
make test
# Submit a PR against mainFollow Conventional Commits:
feat: add HPA support for agent Deployment
fix: correct nginx root path for UBI nginx-122
docs: expand troubleshooting section
refactor: extract cluster detection into standalone package
Open a GitHub issue with:
- OpenShift version (
oc version) - Operator version / commit
oc describe openshiftpulse <name> -n <ns>- Relevant pod logs
MIT — Copyright (c) 2026 Ali Mobrem / Red Hat CoE