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 (also retained — see below), 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 ServiceAccount + ClusterRole (read-only) + ClusterRoleBinding, Deployment, Service (optional, spec.agent.mcp.enabled: true) |
| Network | Per-component ingress-only NetworkPolicies: UI (OCP ingress + Prometheus), PostgreSQL (agent pod only), agent (UI pod + Prometheus), MCP server (agent pod only) |
| 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.
The pg-data PVC (from the StatefulSet's volumeClaimTemplates) has no retention policy and is never deleted automatically, and the {name}-pg-auth credentials Secret has no OwnerReference for the same reason — postgres only runs initdb (which bakes a password into PGDATA) on an empty data directory, so if the Secret were garbage-collected with the CR, recreating a CR with the same name would generate a fresh random password that could never match the already-initialized data on the retained volume, leaving the agent permanently unable to authenticate with no self-heal short of manually deleting the PVC. Retaining both together means recreating a CR with the same name transparently reuses the matching credentials.
If you actually want a full teardown (delete the data and credentials, not just the CR), annotate the CR before deleting it:
oc annotate openshiftpulse pulse -n openshiftpulse pulse.ai/delete-data=true
oc delete openshiftpulse pulse -n openshiftpulseWithout this annotation, {name}-pg-auth and the pg-data PVC are left behind after oc delete openshiftpulse pulse — this is intentional, not a leak.
The agent and UI ship as a pair under one version number, so the operator
compares the two tags pinned in the CR and reports the verdict as an
AgentUIVersionsMatch status condition:
oc get openshiftpulse pulse -n openshiftpulse \
-o jsonpath='{.status.conditions[?(@.type=="AgentUIVersionsMatch")]}{"\n"}'This compares the two pinned tags against each other, not against the newest
published release — that needs no network call in the reconcile path, stays
deterministic, and does not nag clusters that have deliberately stayed on an
older version. It reports and never blocks: an upgrade patches the two images
moments apart, and blocking on the resulting transient mismatch would wedge the
very rollout that clears it. Digest pins, latest, and unset images all report
no skew rather than guessing.
The operator versions independently of the Pulse application it deploys. As of this release the operator is v0.7.0 while the agent and UI ship v2.27.0 — that gap is deliberate, not drift:
- The operator's version tracks its own API and reconcile behaviour. The CRD
is still
v1alpha1, and a 0.x version says so honestly. - OLM upgrade graphs are monotonic. Folding the operator into the application's 2.x stream would be irreversible, and would force an operator release, bundle rebuild, catalog render and a cluster-wide OLM upgrade for every application patch — including releases that change nothing in the operator.
- The application version is already carried explicitly, per instance, in
spec.agent.imageon the OpenShiftPulse CR. That is the field to look at to answer "which Pulse am I running", and it is deliberately decoupled from the operator that reconciles it.
OperatorVersion in internal/controller/compat.go is the operator build's own
version and must be bumped with each release; version_guard_test.go fails the
build if it falls behind the latest git tag.
OpenShift only — this will not install on vanilla Kubernetes. The operator creates
Routeobjects for ingress,OAuthClientobjects for single sign-on, and readsconfig.openshift.io/v1 Ingressto discover the cluster's application domain. There is no Ingress fallback and no capability detection, so on a cluster without those APIs the reconcile fails rather than degrading to something usable. Supporting plain Kubernetes would mean an Ingress path and a different auth story; that work has not been done.
- 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 README is the canonical install guide for all of Pulse. The pulse-agent and pulse-ui repos link here rather than restating the steps, so this is the only copy that has to stay correct.
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 secondsPrivate quay.io namespaces:
quay.io/amobrem/pulse-operator-catalogandpulse-operator-bundleare public, so this doesn't apply to using this repo as-is — but if you fork this and publish to a private namespace instead, read on. A private catalog repository means the cluster's nodes cannot pull it directly and the catalog pod fails withImagePullBackOff. Worse, even once the catalog image itself is reachable, its content still embeds a reference to the bundle image (quay.io/.../pulse-operator-bundle:*) asbundlePath— if that is also private,oc get csvshowsSucceededmisleadingly quickly while the underlying Subscription hangs forever onBundleUnpacking: UnpackingInProgresswith no error logged, because OLM can't pull the bundle image it's unpacking either. Simplest fix: make both repositories public, matching the operator/agent/UI images (Repository Settings → Visibility → Make Public in the quay.io UI). If that's not an option, the reliable fallback is to mirror both images into the cluster's own internal registry (via its public route) and re-render the catalog to reference the internal path instead of quay.io forbundlePath— see Build the catalog image for the exact steps.
oc 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 openshiftpulseThe operator's own controller runs in pulse-operator-system (matching the
manifest-install path below) — separate from openshiftpulse, which holds
the CR and everything it manages. Since the CSV supports only the
AllNamespaces install mode, the operator watches OpenShiftPulse CRs
cluster-wide regardless of which namespace its own controller runs in.
oc new-project pulse-operator-system
cat <<EOF | oc apply -f -
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
name: pulse-operator-group
namespace: pulse-operator-system
spec:
targetNamespaces: []
---
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: pulse-operator
namespace: pulse-operator-system
spec:
channel: alpha
name: pulse-operator
source: pulse-operator-catalog
sourceNamespace: openshift-marketplace
installPlanApproval: Automatic
EOFWatch the CSV reach Succeeded:
oc get csv -n pulse-operator-system -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
minOperatorVersion: "" # optional semver floor for this operator build; unset (default) = inert — see "Agent-version compatibility gate" below
# ── 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. |
spec.temporal.enabled: true provisions a Temporal server ({name}-temporal,
temporalio/auto-setup pinned) backed by the operator's own PostgreSQL — the
temporal and temporal_visibility databases are created in the same
instance on first start — and injects PULSE_AGENT_TEMPORAL_HOST into the
agent, which enables its durable plan-run endpoints (agent docs/TEMPORAL.md).
This is the dev-grade single-container topology; a production topology
(separated services, dedicated visibility store) is a deliberate later step.
Enabling on an existing install: the CREATEDB grant that lets Temporal create its databases ships as a postgresql-start script, but the PostgreSQL pod template is create-only — fresh installs get it automatically, existing installs need the one-time grant:
oc exec -n <ns> <name>-openshift-sre-agent-postgresql-0 -- \
sh -c 'psql -U postgres -c "ALTER USER \"$POSTGRESQL_USER\" CREATEDB;"'Workflow history (spec.temporal.ui): ui: true also deploys the Temporal
Web UI ({name}-temporal-ui), where every run's full history — each activity
attempt, retry, durable timer and approval signal — is inspectable without a
CLI. It is off by default, and it gets a Service but deliberately no
Route: the Temporal UI ships with no authentication of its own and offers a
"terminate workflow" button to anyone who can load it, so a public Route would
be an unauthed kill switch for every in-flight fix. Reach it locally:
oc port-forward -n <ns> svc/<name>-temporal-ui 8080:8080Front it with an oauth-proxy before exposing it permanently.
status:
phase: Running # Installing | Running | Upgrading | Degraded
agentVersion: v2.27.0 # tag portion of the running spec.agent.image
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
upgradeStartedAt: null # set while an image differs from the last known-healthy one; cleared when healthy again or after auto-rollback
lastHealthyAgentImage: quay.io/amobrem/pulse-agent:v2.9.0 # rollback target — see "Automatic rollback" below
lastHealthyUIImage: quay.io/amobrem/openshiftpulse:e6169a4
lastUpgradeDurationSeconds: 0 # how long the most recently completed agent/UI upgrade took to become healthy; 0/absent if none has happened yet — see "Agent / UI image upgrades" below
conditions:
- type: Ready # aggregate — kept for backward compatibility
status: "True"
reason: AllComponentsHealthy
- type: AgentReady
status: "True"
reason: Ready
- type: DatabaseReady
status: "True"
reason: Ready
- type: UIReady
status: "True"
reason: Ready
- type: Progressing # True while Installing/Upgrading; False (Stable/Degraded) otherwise
status: "False"
reason: Stable
- type: AgentUIVersionsMatch # agent and UI image tags agree — see "Agent / UI image skew" below
status: "True"
reason: VersionsMatch
# - type: AgentVersionCompatible # only present once spec.agent.minOperatorVersion is set — see "Agent-version compatibility gate" below
# status: "True"
# reason: CompatiblePhase: Upgrading is distinct from Degraded: it means spec.agent.image/spec.ui.image was
changed and the new image isn't ready yet, not that something broke. The operator only reports
Degraded for a component that was previously healthy and is no longer — not for an upgrade in
progress. See Automatic rollback below for what happens if an upgrade never becomes healthy.
The operator emits Normal SelfHealed Events (oc get events / oc describe openshiftpulse)
whenever it takes one of its own corrective actions: deleting a PostgreSQL pod stuck Pending for
over 2 minutes, deleting an agent or UI Deployment pod stuck Pending too long or definitively
failing with ImagePullBackOff/CrashLoopBackOff (no grace period needed for those two — the
kubelet only ever reports them after a real failed pull or start-then-exit attempt, so there's no
"might just be mid-startup" ambiguity to wait out), recreating a selector-mismatched
StatefulSet/Deployment (e.g. adopting a previously Helm-managed instance), correcting Route drift,
or regenerating a malformed cookie-secret. The agent/UI case matters most for the agent
specifically, whose Deployment uses the Recreate strategy (see Agent / UI image upgrades
below): Recreate tears the old pod down before the new one starts, so a bad rollout has no old
pod left to fall back to without this — previously it stayed stuck until a human noticed and ran
oc delete pod by hand.
It also exposes five Prometheus metrics on the manager's :8082/metrics endpoint:
pulse_operator_self_heal_actions_total{component,action}— counts of the actions above.pulse_operator_component_ready{namespace,name,component}— 1/0 mirror of the AgentReady/DatabaseReady/UIReady conditions.pulse_operator_reconcile_errors_total{step}— reconcile failures by step (e.g.postgres,agent,ui,status_update).pulse_operator_observed_memory_bytes{namespace,name,component}— real memory usage observed viametrics.k8s.io(max across replicas), for comparing against the request below. Advisory only — the operator never patchesresources.requestsfrom this. Absent (not zero) whenmetrics.k8s.iohas no sample yet, e.g. no metrics-server, or the pod isn'tRunning; sampled at most once every 5 minutes per component to keep load off the metrics API.pulse_operator_requested_memory_bytes{namespace,name,component}— the effectiveresources.requests.memorycurrently applied to that component's container (spec override or built-in default).
spec.agent.minOperatorVersion (see CR Spec above) is an optional, admin-set semver floor —
left unset, the default for every existing CR, this gate is completely inert. When set, the
operator compares it against its own running version on every agent reconcile. If this operator
build doesn't satisfy the constraint, it records an AgentVersionCompatible condition
(status: "False", reason: IncompatibleVersion) on the CR, emits a Warning IncompatibleVersion
Event, and pins the agent Deployment to whichever image is already running rather than applying
the requested spec.agent.image change — or, if there's no Deployment yet to pin, refuses to
create one and requeues instead of erroring. A malformed version string on either side fails
open (treated as compatible) rather than blocking a real deployment on a typo the CRD doesn't
itself validate. This is checked against this specific operator build's own version, not the
agent's — it exists to catch an operator upgrade running ahead of (or behind) an agent image that
expects a newer API/DB schema, the axis the agent's own /version UI↔agent protocol check
(documented in pulse-agent) doesn't cover.
OLM 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"}}}'status.phase reports Upgrading while the new image is rolling out (see CR Status above).
Once the new image becomes healthy again, status.lastUpgradeDurationSeconds records how long
that outage window actually took, and holds its previous value between upgrades (0 before the
first one ever completes).
The agent Deployment uses the Recreate strategy, not RollingUpdate — a deliberate choice, not
an unexamined default. Its memory-cache PVC is ReadWriteOnce, and pulse-agent's own Helm chart
runs Recreate for the identical reason (chart/values.yaml: "Required because the memory PVC
is ReadWriteOnce (RWO) and cannot be mounted by two pods simultaneously") — a RollingUpdate
overlap here would leave the surging pod's volume attach stuck Pending
(FailedAttachVolume), arguably a worse failure mode than today's brief, bounded stop-then-start
outage. The agent also runs forward-only, no-rollback DB migrations automatically on startup, so
two concurrent agent versions sharing one Postgres instance is a second, independent reason to
avoid the overlap. lastUpgradeDurationSeconds exists to make that outage window's size visible
and measured, not to eliminate it — see the self-heal coverage above for what happens if the new
pod gets stuck instead of just taking a while.
If the new agent/UI image doesn't become healthy within 5 minutes of Phase turning Upgrading,
the operator automatically reverts spec.agent.image/spec.ui.image back to
status.lastHealthy{Agent,UI}Image — the last image that was actually observed ready — and emits
a Warning AutoRolledBack Event explaining what happened. This only covers the agent/UI images
managed directly by this CR; it does not cover an operator upgrade via OLM (rolling back the
operator's own running binary needs a separate pre-upgrade gate outside this process, which isn't
implemented yet).
# Delete CR — operator finalizer cleans up ClusterRoles and OAuthClient
oc delete openshiftpulse pulse -n openshiftpulse
# Remove operator (OLM install) — use scripts/olm-uninstall.py instead of just
# these two deletes if you skipped the CR deletion above; see that script's
# docstring for why (this order is safe specifically because the CR is
# already gone by this point).
oc delete subscription pulse-operator -n pulse-operator-system
oc delete csv -n pulse-operator-system -l operators.coreos.com/pulse-operator.pulse-operator-system=
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.
The steps above work for pulse-operator specifically because deleting its CR
first (via the pulse.ai/cleanup finalizer) tears down everything it owns
before the operator itself goes away. Manually running just
oc delete subscription + oc delete csv for some other operator skips
that step entirely — it removes the controller but leaves any custom
resources (and everything those CRs caused to be created) running forever,
orphaned. OLM also never deletes CRDs on uninstall, by design.
scripts/olm-uninstall.py does this properly for
any OLM-installed operator: it reads the CSV's own
spec.customresourcedefinitions.owned[] to find every CR instance the
operator manages, deletes those first, then removes the Subscription, CSV,
and any stale InstallPlans.
scripts/olm-uninstall.py authorino # dry run — shows what would be deleted
scripts/olm-uninstall.py authorino --yes # actually uninstallgo 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)
# --zap-devel switches to human-readable console logs; the production default
# (used by the shipped Deployment) is structured JSON.
go run ./cmd/main.go \
--leader-elect=false \
--metrics-bind-address=:9191 \
--health-probe-bind-address=:9292 \
--zap-devel
# 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 generatemanifests regenerates config/crd/bases/pulse.ai_openshiftpulses.yaml and generate regenerates api/v1alpha1/zz_generated.deepcopy.go, both via controller-gen (auto-installed into bin/ on first run). After manifests changes the CRD, also run make bundle to re-sync bundle/manifests/pulse.ai_openshiftpulses.yaml — CI's rbac-and-crd-sync job fails the build if either copy drifts from what these targets would produce, or if zz_generated.deepcopy.go drifts from api/v1alpha1/types.go.
RBAC is not regenerated by these targets: config/rbac/role.yaml is hand-curated (it includes several OpenShift-specific API groups that don't come from +kubebuilder:rbac markers) and is the source of truth for the two generated copies in deploy/operator.yaml and the CSV — see scripts/sync-rbac.py's docstring, and run scripts/sync-rbac.py --fix after editing it.
The catalog image is a File-Based Catalog (FBC) served by opm. This is not
automated by CI (see Releasing a new version below) —
rebuild and push it manually after bundle changes:
# 1. Push the bundle image first (see the release process below) — the next
# step needs to render FROM it, not from the local bundle/ directory.
# Rendering from a bare local directory (`opm render /bundle`) produces an
# olm.bundle entry with an EMPTY `image:`/bundlePath field, since there's
# no registry reference for a directory that was never pushed anywhere —
# OLM then can't ever unpack it. Always render from the pushed image.
BUNDLE_IMG=quay.io/amobrem/pulse-operator-bundle:v0.2.0
mkdir -p catalog
podman run --rm quay.io/operator-framework/opm:latest \
render "$BUNDLE_IMG" -o yaml > /tmp/catalog.yaml
# 2. Prepend package + channel declarations (update the version to match).
cat - /tmp/catalog.yaml > catalog/full-catalog.yaml <<'HEADER'
---
defaultChannel: alpha
name: pulse-operator
schema: olm.package
---
entries:
- name: pulse-operator.v0.2.0
replaces: pulse-operator.v0.1.0
name: alpha
package: pulse-operator
schema: olm.channel
HEADER
# 3. Build catalog image (linux/amd64 — cluster nodes are x86). Dockerfile.catalog
# pre-populates opm's serve cache at build time — required, or the container
# fails at startup with "integrity check failed: read existing cache digest".
podman build --platform linux/amd64 \
-f Dockerfile.catalog \
-t quay.io/amobrem/pulse-operator-catalog:latest .
podman push quay.io/amobrem/pulse-operator-catalog:latest
rm -rf catalog # generated — not committed (see .gitignore)No registry credentials for your quay.io namespace on the cluster? Mirror both images into the cluster's own internal registry via its public route instead of relying on quay.io being reachable/public:
REGISTRY=$(oc get route default-route -n openshift-image-registry -o jsonpath='{.spec.host}') TOKEN=$(oc whoami -t) podman login -u kubeadmin -p "$TOKEN" "$REGISTRY" --tls-verify=false for image in bundle catalog; do podman tag "quay.io/amobrem/pulse-operator-$image:v0.2.0" \ "$REGISTRY/pulse-operator-catalog/$image:v0.2.0" podman push --tls-verify=false "$REGISTRY/pulse-operator-catalog/$image:v0.2.0" doneThen render step 1 from
$REGISTRY/pulse-operator-catalog/bundle:v0.2.0instead (sobundlePathin the rendered YAML points somewhere the cluster can actually pull from), and use the internalcatalogtag —image-registry.openshift-image-registry.svc:5000/pulse-operator-catalog/catalog:v0.2.0— as the CatalogSource'sspec.imagein step 1 of Install via OLM. This mirroring only needs cluster-adminocaccess, not registry credentials.
make bundle-validateTag to trigger CI publishing:
git tag v0.2.0 -m "v0.2.0: ..."
git push origin v0.2.0CI (.github/workflows/release.yml) builds and pushes:
quay.io/amobrem/pulse-operator:v0.2.0+:latest(the operator image, fromDockerfile)quay.io/amobrem/pulse-operator-bundle:v0.2.0+:latest(the OLM bundle, fromDockerfile.bundle)
Before the bundle image is built, CI runs scripts/bump-bundle-version.py against the pushed tag, which updates the CSV's metadata.name, spec.version, metadata.annotations.containerImage, and the manager Deployment's image to match — and sets spec.replaces to whatever CSV name was current before the bump, extending the upgrade graph by one step. This runs in CI only; the version bump is never committed back to main, so bundle/manifests/pulse-operator.clusterserviceversion.yaml on main always reflects the most recently released version, not a preview of the next one.
The catalog image (pulse-operator-catalog, used by the CatalogSource in
Install via OLM) is not built by this workflow — publish it
manually via Build the catalog image above after
tagging a release.
Requires QUAY_USERNAME and QUAY_TOKEN as GitHub repository secrets.
pulse-operator-system/
└── pulse-operator-manager (Deployment — 1 replica, no leader election)
└── OpenShiftPulseReconciler
├── pulse.ai/cleanup finalizer ← removes cluster-scoped resources on CR delete
│
├── AgentReconciler
│ ├── {ns}/{name}-openshift-sre-agent (ServiceAccount)
│ ├── {ns}-{name}-openshift-sre-agent (ClusterRole + ClusterRoleBinding — cluster-scoped, namespace-qualified)
│ ├── {ns}-{name}-openshift-sre-agent-monitoring-view (ClusterRoleBinding → built-in cluster-monitoring-view, for the agent's own Thanos-querier reads)
│ ├── {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)
│ ├── {ns}-{name}-openshiftpulse-reader (ClusterRole + ClusterRoleBinding [+ -auth-delegator] — cluster-scoped)
│ ├── {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 (ServiceAccount + ClusterRole + ClusterRoleBinding, read-only)
│ ├── {ns}/{name}-mcp-server (Deployment + Service :8081)
│ └── {ns}/{name}-mcp-server (NetworkPolicy — ingress from the agent pod only)
│
└── NetworkPolicyReconciler
├── {ns}/{name}-openshiftpulse (UI: ingress from OCP router + Prometheus only)
├── {ns}/{name}-pg-access (PG: ingress from the agent pod only)
└── {ns}/{name}-agent-access (Agent: ingress from the UI pod + Prometheus 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 openshiftpulseSymptom: oc get pod -o jsonpath='{...lastState}' shows "reason":"Error","exitCode":137, and oc get events shows Liveness probe failed: ... context deadline exceeded — but the agent's own logs show nothing wrong, and oc adm top pod shows normal CPU/memory usage.
Cause: The agent deliberately has no CPU request (see agentResources in agent_reconciler.go), so it gets minimal scheduling priority. On a cluster where other pods on the same node are bursting CPU, the agent's process can occasionally take longer than the probe timeout to answer /healthz even though it's healthy — the probes now use a 5s timeout (was the Kubernetes default of 1s) to tolerate that. If restarts continue after upgrading, check node-level CPU contention (oc adm top nodes, oc describe node <node> → Allocated resources) rather than the agent itself.
Cause: The agent's alert-scanning queries hit thanos-querier directly using its own ServiceAccount token; that read is gated by binding to the built-in cluster-monitoring-view ClusterRole, not by anything addable to the agent's own ClusterRole. If this binding is missing (e.g. a CR created before this was added), a normal reconcile creates it — force one with:
oc get clusterrolebinding | grep monitoring-view
# If absent, touch an annotation to trigger reconcile, or restart the operator podCause: The Alerts view reads firing alerts/rules from /api/prometheus/ (Thanos-querier) but silences from a separate /api/alertmanager/ proxy — a pre-existing gap where that location didn't exist in nginx at all, so requests fell through to the SPA's own index.html (200 OK, text/html) instead of reaching Alertmanager. The UI correctly detected the non-JSON response and reported the backend as unreachable, even though Prometheus itself was fine.
# Confirm the proxy exists in the live ConfigMap
oc get configmap {name}-nginx -n <namespace> -o jsonpath='{.data.nginx\.conf}' | grep -A5 'location /api/alertmanager/'If it's missing, the operator image predates this fix — upgrade and restart the UI pods. Also confirm the logged-in user holds monitoring-alertmanager-view (or -edit) in openshift-monitoring; cluster-monitoring-view alone (which covers the Thanos path) is not sufficient for silences.
The 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.
See SECURITY.md to report a vulnerability.
- 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 own ClusterRole (
config/rbac/role.yaml) does not includeescalate/bindon RBAC resources — every rule it ever writes into a generated agent/UI/MCP ClusterRole is already a permission it holds itself, so Kubernetes' RBAC "you already have this" rule letscreate/updatesucceed without those verbs. It's still a privilege-concentration point (it creates ClusterRoles/ClusterRoleBindings for every managed instance): restrict exec access topulse-operator-systemvia NetworkPolicy. - The agent, UI, PostgreSQL, and MCP server pods each get their own NetworkPolicy restricting ingress to only the pods/namespaces that legitimately call them (e.g. only the UI pod may reach the agent on :8080; only the agent pod may reach the MCP server on :8081) — no pod is reachable cluster-wide by default.
- Every cluster-scoped resource the operator creates — the agent/UI/MCP ClusterRoles and ClusterRoleBindings, the agent's
-monitoring-viewbinding, and the OAuthClient — is named{namespace}-{name}-…to prevent collision when multiple CRs coexist on the same cluster. Namespaced resources keep plain{name}-…names; Kubernetes already scopes those. - The agent's ServiceAccount is bound to OpenShift's built-in
cluster-monitoring-viewClusterRole (read-only) so its own alert-scanning/trend-monitoring features can querythanos-querier— this is separate from, and in addition to, the agent's own scoped-down ClusterRole. - The UI's nginx proxies both
/api/prometheus/(Thanos-querier — firing alerts, rules, CPU/memory charts) and/api/alertmanager/(Alertmanager — silences: list/create/expire) toopenshift-monitoring, forwarding the logged-in user's own OAuth token. Reading/writing silences requires the user to additionally holdmonitoring-alertmanager-view(read) ormonitoring-alertmanager-edit(read+write) in theopenshift-monitoringproject — a narrower grant thancluster-monitoring-view, which only covers the Thanos path. - Secrets are generated once and never rotated automatically.
{name}-ws-tokenand{name}-oauth-secretscan be rotated by deleting the Secret — the operator regenerates it on the next reconcile. - Do not rotate
{name}-pg-auththis way. Deleting it makes the operator generate a fresh random password, but postgres only bakes a password intoPGDATAduringinitdbon an empty data directory — the retained pg-data PVC still holds the old one, so the agent is left permanently unable to authenticate (see above). Rotating the PostgreSQL password means changing it inside the running database and in the Secret together, or tearing both down withpulse.ai/delete-data=trueand letting the stack reinitialise. - Container images (
Dockerfile,Dockerfile.bundle) are pinned by digest, not just tag, for reproducible builds; Dependabot (.github/dependabot.yml) opens a PR when a pinned digest moves.
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