<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-04-08T17:33:53+00:00</updated><id>/feed.xml</id><title type="html">TinyShips</title><subtitle>Quick whys and how-tos for doing new and interesting things.</subtitle><entry><title type="html">Querying OpenShift Logs with LokiStack and Grafana</title><link href="/2026/04/08/visualizing-openshift-logs-grafana.html" rel="alternate" type="text/html" title="Querying OpenShift Logs with LokiStack and Grafana" /><published>2026-04-08T00:00:00+00:00</published><updated>2026-04-08T00:00:00+00:00</updated><id>/2026/04/08/visualizing-openshift-logs-grafana</id><content type="html" xml:base="/2026/04/08/visualizing-openshift-logs-grafana.html"><![CDATA[<p>The <a href="/2026/04/03/forwarding-openshift-logs-syslog.html">previous post</a> covered forwarding OpenShift logs to an external syslog server using the Logging Operator and a <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code>. That handles the durability problem — logs leave the cluster and land somewhere they can outlive it. This post adds the other half: querying and visualizing those same logs interactively inside the cluster using LokiStack and Grafana. The <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> from the previous post gets a second output added, so syslog forwarding keeps working and Loki gets the same stream alongside it.</p>

<h2 id="why-this-matters">Why This Matters</h2>

<p>Syslog gets your logs off the cluster. What it does not give you is a fast, interactive way to ask questions about them while you are debugging. Tailing a forwarded log file or writing a query against a SIEM is useful for forensics, not for active troubleshooting where you are iterating quickly.</p>

<p>LokiStack brings structured log querying into the cluster. You can filter by namespace, label, pod name, log level, or any combination — and because LokiStack runs in <code class="language-plaintext highlighter-rouge">openshift-logging</code> tenancy mode, it enforces OpenShift RBAC automatically. Users can only query logs from namespaces they already have access to.</p>

<p>Grafana connects to LokiStack as a datasource and gives you the Explore view: live log tailing, label-based filtering, and histogram overlays over time. It is worth slowing down to set this up properly — once it is in place, it changes how you investigate problems in the cluster.</p>

<hr />

<h2 id="the-steps">The Steps</h2>

<ol>
  <li>Install the Red Hat Loki Operator into a dedicated namespace, and the community Grafana Operator into its own</li>
  <li>Provision an S3 bucket via OpenShift Data Foundation and deploy a LokiStack backed by it</li>
  <li>Deploy Grafana with a ServiceAccount that has the minimum permissions the LokiStack gateway requires</li>
  <li>Update the <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> to add LokiStack as a second output alongside syslog</li>
  <li>Query logs in Grafana Explore</li>
</ol>

<hr />

<h2 id="how-to-do-it">How To Do It</h2>

<h3 id="step-1-install-the-operators">Step 1: Install the Operators</h3>

<p>Two operators are needed: the Red Hat Loki Operator and the community Grafana Operator. If you want a primer on how OLM and operator installation works before diving in, <a href="/2026/04/06/exploring-openshift-operators.html">How to Find, Install, and Explore an OpenShift Operator from the CLI</a> is a good starting point.</p>

<p>The Grafana Operator installs namespace-scoped into its own dedicated namespace. Both are installed into dedicated namespaces rather than the default <code class="language-plaintext highlighter-rouge">openshift-operators</code>. The reasons for that are worth understanding — a dedicated post covering exactly why is coming soon.</p>

<p>📄 <a href="/posts/visualizing-openshift-logs-grafana/1-install-operators.yaml">1-install-operators.yaml</a></p>

<p>This file creates six resources to install both operators:</p>

<h4 id="loki-operator-namespace-operatorgroup-and-subscription">Loki Operator Namespace, OperatorGroup, and Subscription</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Namespace</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">loki-operator</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">OperatorGroup</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">loki-operator-group</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">loki-operator</span>
<span class="na">spec</span><span class="pi">:</span> <span class="pi">{}</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Subscription</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">loki-operator</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">loki-operator</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">channel</span><span class="pi">:</span> <span class="s">stable-6.5</span>
  <span class="na">installPlanApproval</span><span class="pi">:</span> <span class="s">Automatic</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">loki-operator</span>
  <span class="na">source</span><span class="pi">:</span> <span class="s">redhat-operators</span>
  <span class="na">sourceNamespace</span><span class="pi">:</span> <span class="s">openshift-marketplace</span>
</code></pre></div></div>

<p>The Loki Operator needs “All Namespaces” install scope — it has to watch for <code class="language-plaintext highlighter-rouge">LokiStack</code> resources in any namespace across the cluster. <code class="language-plaintext highlighter-rouge">spec: {}</code> on the <code class="language-plaintext highlighter-rouge">OperatorGroup</code> (no <code class="language-plaintext highlighter-rouge">targetNamespaces</code> set) signals that. This gives the same watch scope as the <code class="language-plaintext highlighter-rouge">global-operators</code> group in <code class="language-plaintext highlighter-rouge">openshift-operators</code>, but the operator’s own lifecycle stays isolated to the <code class="language-plaintext highlighter-rouge">loki-operator</code> namespace. It manages <code class="language-plaintext highlighter-rouge">LokiStack</code> resources and deploys the gateway, compactor, and storage components.</p>

<p>Most operators publish a <code class="language-plaintext highlighter-rouge">stable</code> channel you can subscribe to without pinning a version. The Loki Operator does not — its channels are versioned, like <code class="language-plaintext highlighter-rouge">stable-6.2</code> or <code class="language-plaintext highlighter-rouge">stable-6.5</code>. If you copy the channel name from this post and that version is no longer current, your Subscription will fail to resolve. Check what is actually available on your cluster before applying:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest loki-operator <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.channels[*].name}'</span>
</code></pre></div></div>

<p>Use whatever <code class="language-plaintext highlighter-rouge">stable-x.x</code> value comes back — that is the channel name to put in the Subscription.</p>

<h4 id="grafana-operator-namespace-operatorgroup-and-subscription">Grafana Operator Namespace, OperatorGroup, and Subscription</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Namespace</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-logging</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">OperatorGroup</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-operator-group</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">grafana-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">targetNamespaces</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">grafana-logging</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Subscription</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-operator</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">grafana-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">channel</span><span class="pi">:</span> <span class="s">v5</span>
  <span class="na">installPlanApproval</span><span class="pi">:</span> <span class="s">Automatic</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-operator</span>
  <span class="na">source</span><span class="pi">:</span> <span class="s">community-operators</span>
  <span class="na">sourceNamespace</span><span class="pi">:</span> <span class="s">openshift-marketplace</span>
</code></pre></div></div>

<p>The Grafana Operator installs namespace-scoped into a dedicated <code class="language-plaintext highlighter-rouge">grafana-logging</code> namespace from the community catalog on channel <code class="language-plaintext highlighter-rouge">v5</code>. The <code class="language-plaintext highlighter-rouge">OperatorGroup</code> scopes it to that namespace — without one, the Subscription will stall.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 1-install-operators.yaml
</code></pre></div></div>

<p>Wait for both CSVs to reach <code class="language-plaintext highlighter-rouge">Succeeded</code> before moving on — do not continue until both show <code class="language-plaintext highlighter-rouge">Succeeded</code>: 
<strong>This could take a minute show up</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get csv <span class="nt">-n</span> loki-operator <span class="nt">-w</span>
oc get csv <span class="nt">-n</span> grafana-logging <span class="nt">-w</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME                       DISPLAY           VERSION   PHASE
loki-operator.v6.5.0       Loki Operator     6.5.0     Succeeded

NAME                          DISPLAY            VERSION   PHASE
grafana-operator.v5.x.x       Grafana Operator   5.x.x     Succeeded
</code></pre></div></div>

<hr />

<h3 id="step-2-deploy-the-lokistack">Step 2: Deploy the LokiStack</h3>

<p>LokiStack needs an S3-compatible object store to write log chunks. This can be any S3-compatible target — AWS S3, Google Cloud Storage, Azure Blob, MinIO, or anything else that speaks the S3 API. The <code class="language-plaintext highlighter-rouge">LokiStack</code> spec just needs an endpoint, bucket name, and credentials in a Secret. Since we have OpenShift Data Foundation available, we are using NooBaa, which is ODF’s built-in S3-compatible object store.</p>

<p>Two resources drive this step. You do not apply them directly — the script below handles them in the correct order. Read through them first so the script output makes sense.</p>

<h4 id="objectbucketclaim">ObjectBucketClaim</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">objectbucket.io/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ObjectBucketClaim</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logging-loki-bucket</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">generateBucketName</span><span class="pi">:</span> <span class="s">logging-loki</span>
  <span class="na">storageClassName</span><span class="pi">:</span> <span class="s">openshift-storage.noobaa.io</span>
</code></pre></div></div>

<p>An <code class="language-plaintext highlighter-rouge">ObjectBucketClaim</code> is how you request a bucket from NooBaa. When this is created, the OBC controller provisions the bucket and writes the connection details — host, port, bucket name, and credentials — into a Secret and ConfigMap in the same namespace, both named after the OBC. Those values are not available until the OBC reaches <code class="language-plaintext highlighter-rouge">Bound</code>, which is why this cannot simply be applied as part of a single YAML file.</p>

<h4 id="lokistack">LokiStack</h4>

<p>📄 <a href="/posts/visualizing-openshift-logs-grafana/3-lokistack.yaml">3-lokistack.yaml</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">loki.grafana.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">LokiStack</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logging-loki</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">size</span><span class="pi">:</span> <span class="s">1x.demo</span>
  <span class="na">storage</span><span class="pi">:</span>
    <span class="na">schemas</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">version</span><span class="pi">:</span> <span class="s">v13</span>
      <span class="na">effectiveDate</span><span class="pi">:</span> <span class="s2">"</span><span class="s">2024-10-01"</span>
    <span class="na">secret</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">logging-loki-s3</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s">s3</span>
  <span class="na">storageClassName</span><span class="pi">:</span> <span class="s">ocs-external-storagecluster-ceph-rbd</span>
  <span class="na">tenants</span><span class="pi">:</span>
    <span class="na">mode</span><span class="pi">:</span> <span class="s">openshift-logging</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">size: 1x.demo</code> is a single-replica layout appropriate for non-production clusters. <code class="language-plaintext highlighter-rouge">tenants.mode: openshift-logging</code> wires Loki directly into OpenShift RBAC — users can only query logs from namespaces they already have access to, with no additional tenant configuration needed. The <code class="language-plaintext highlighter-rouge">secret.name: logging-loki-s3</code> is the Secret the script builds from the OBC credentials before this manifest is applied.</p>

<h4 id="the-script">The Script</h4>

<p>📄 <a href="/posts/visualizing-openshift-logs-grafana/2-lokistack-setup.sh">2-lokistack-setup.sh</a></p>

<p>The script applies the two resources above in the correct sequence:</p>

<ol>
  <li>Creates the <code class="language-plaintext highlighter-rouge">openshift-logging</code> namespace if it does not already exist (idempotent — safe to run if the Logging Operator already created it)</li>
  <li>Creates the <code class="language-plaintext highlighter-rouge">ObjectBucketClaim</code> and waits for it to reach <code class="language-plaintext highlighter-rouge">Bound</code></li>
  <li>Extracts <code class="language-plaintext highlighter-rouge">BUCKET_HOST</code>, <code class="language-plaintext highlighter-rouge">BUCKET_PORT</code>, <code class="language-plaintext highlighter-rouge">BUCKET_NAME</code>, <code class="language-plaintext highlighter-rouge">AWS_ACCESS_KEY_ID</code>, and <code class="language-plaintext highlighter-rouge">AWS_SECRET_ACCESS_KEY</code> from the generated ConfigMap and Secret, then builds the <code class="language-plaintext highlighter-rouge">logging-loki-s3</code> Secret the LokiStack references</li>
  <li>Applies <code class="language-plaintext highlighter-rouge">3-lokistack.yaml</code> and waits for the <code class="language-plaintext highlighter-rouge">Ready</code> condition to be <code class="language-plaintext highlighter-rouge">True</code></li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash 2-lokistack-setup.sh
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>==&gt; Creating namespace openshift-logging...
namespace/openshift-logging configured
==&gt; Creating ObjectBucketClaim...
objectbucketclaim.objectbucket.io/logging-loki-bucket created
==&gt; Waiting for OBC to bind...
  waiting...
  waiting...
==&gt; Extracting S3 credentials...
==&gt; Creating Loki S3 secret...
secret/logging-loki-s3 created
==&gt; Deploying LokiStack...
lokistack.loki.grafana.com/logging-loki created
==&gt; Waiting for LokiStack to be ready (this takes a few minutes)...
  waiting...
  waiting...
LokiStack is ready.
</code></pre></div></div>

<p>Confirm the LokiStack is fully ready before continuing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get lokistack logging-loki <span class="nt">-n</span> openshift-logging
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME           AGE    READY
logging-loki   3m     True
</code></pre></div></div>

<p>Then verify the gateway and storage pods are all running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get pods <span class="nt">-n</span> openshift-logging <span class="nt">-l</span> app.kubernetes.io/instance<span class="o">=</span>logging-loki
</code></pre></div></div>

<hr />

<h3 id="step-3-deploy-grafana">Step 3: Deploy Grafana</h3>

<p>📄 <a href="/posts/visualizing-openshift-logs-grafana/4-grafana.yaml">4-grafana.yaml</a></p>

<p>This file creates five resources in the <code class="language-plaintext highlighter-rouge">grafana-logging</code> namespace.</p>

<h4 id="serviceaccount">ServiceAccount</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ServiceAccount</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-loki-sa</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">grafana-logging</span>
</code></pre></div></div>

<p>This is the identity Grafana uses when authenticating requests to the LokiStack gateway. The gateway validates the token via a TokenReview, then performs SubjectAccessReviews to check what the SA is allowed to see.</p>

<h4 id="clusterrole-and-clusterrolebinding">ClusterRole and ClusterRoleBinding</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterRole</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-loki-reader</span>
<span class="na">rules</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">apiGroups</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">loki.grafana.com"</span><span class="pi">]</span>
  <span class="na">resources</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">application"</span><span class="pi">]</span>
  <span class="na">verbs</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">get"</span><span class="pi">]</span>
<span class="pi">-</span> <span class="na">apiGroups</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">"</span><span class="pi">]</span>
  <span class="na">resources</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">namespaces"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">pods"</span><span class="pi">]</span>
  <span class="na">verbs</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">get"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">list"</span><span class="pi">]</span>
</code></pre></div></div>

<p>The LokiStack gateway (<code class="language-plaintext highlighter-rouge">opa-openshift</code>) performs two SubjectAccessReviews for every request:</p>

<ol>
  <li><strong>Tenant check</strong> — <code class="language-plaintext highlighter-rouge">get application</code> in the <code class="language-plaintext highlighter-rouge">loki.grafana.com</code> API group. <code class="language-plaintext highlighter-rouge">application</code> is not a registered CRD; it is a virtual resource name the gateway uses to represent the application log tenant. RBAC evaluates rules for it correctly even though the resource doesn’t exist as a CRD.</li>
  <li><strong>Namespace check</strong> — <code class="language-plaintext highlighter-rouge">get pods</code> in the namespace(s) referenced by the query labels. This enforces that the caller has actual Kubernetes-level access to the workloads whose logs they are reading.</li>
</ol>

<p>Both checks must pass or the gateway returns <code class="language-plaintext highlighter-rouge">You don't have permission to access this tenant</code>.</p>

<h4 id="secret-token">Secret token</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Secret</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-loki-token</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">grafana-logging</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">kubernetes.io/service-account.name</span><span class="pi">:</span> <span class="s">grafana-loki-sa</span>
<span class="na">type</span><span class="pi">:</span> <span class="s">kubernetes.io/service-account-token</span>
</code></pre></div></div>

<p>A long-lived token for the ServiceAccount. Kubernetes populates the <code class="language-plaintext highlighter-rouge">token</code> key automatically. The <code class="language-plaintext highlighter-rouge">GrafanaDatasource</code> reads it via <code class="language-plaintext highlighter-rouge">valuesFrom</code> and injects it as an Authorization header on every request to the LokiStack gateway.</p>

<h4 id="grafana-and-grafanadatasource">Grafana and GrafanaDatasource</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">grafana.integreatly.org/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Grafana</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logging-grafana</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">grafana-logging</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">grafana</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">route</span><span class="pi">:</span>
    <span class="na">spec</span><span class="pi">:</span> <span class="pi">{}</span>
  <span class="na">config</span><span class="pi">:</span>
    <span class="na">log</span><span class="pi">:</span>
      <span class="na">mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">console"</span>
    <span class="na">auth</span><span class="pi">:</span>
      <span class="na">disable_login_form</span><span class="pi">:</span> <span class="s2">"</span><span class="s">false"</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">grafana.integreatly.org/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">GrafanaDatasource</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">lokistack-datasource</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">grafana-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">instanceSelector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">grafana</span>
  <span class="na">valuesFrom</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">targetPath</span><span class="pi">:</span> <span class="s2">"</span><span class="s">secureJsonData.httpHeaderValue1"</span>
    <span class="na">valueFrom</span><span class="pi">:</span>
      <span class="na">secretKeyRef</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-loki-token</span>
        <span class="na">key</span><span class="pi">:</span> <span class="s">token</span>
  <span class="na">datasource</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Loki</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">loki</span>
    <span class="na">access</span><span class="pi">:</span> <span class="s">proxy</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s">https://logging-loki-gateway-http.openshift-logging.svc.cluster.local:8080/api/logs/v1/application/</span>
    <span class="na">isDefault</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">jsonData</span><span class="pi">:</span>
      <span class="na">tlsSkipVerify</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">httpHeaderName1</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Authorization"</span>
      <span class="na">maxLines</span><span class="pi">:</span> <span class="m">1000</span>
    <span class="na">secureJsonData</span><span class="pi">:</span>
      <span class="na">httpHeaderValue1</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Bearer</span><span class="nv"> </span><span class="s">${token}"</span>
</code></pre></div></div>

<p>The datasource URL points directly at the LokiStack gateway’s internal service for the application log tenant. <code class="language-plaintext highlighter-rouge">tlsSkipVerify: true</code> is used here because we are connecting to an internal service using the cluster’s self-signed CA — in production you would mount the CA bundle instead.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 4-grafana.yaml
</code></pre></div></div>

<p>Verify the Grafana pod is running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get pods <span class="nt">-n</span> grafana-logging <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span>logging-grafana
</code></pre></div></div>

<p>Confirm the datasource was successfully pushed to the Grafana instance:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get grafanadatasource lokistack-datasource <span class="nt">-n</span> grafana-logging <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.conditions[?(@.type=="DatasourceSynchronized")].message}'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Datasource was successfully applied to 1 instances
</code></pre></div></div>

<hr />

<h3 id="step-4-update-the-clf-to-dual-output">Step 4: Update the CLF to Dual-Output</h3>

<p>📄 <a href="/posts/visualizing-openshift-logs-grafana/5-clf-dual-output.yaml">5-clf-dual-output.yaml</a></p>

<p>This file replaces the <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> from the previous post with a dual-output version, and adds a <code class="language-plaintext highlighter-rouge">ClusterRoleBinding</code> the collector needs to write to LokiStack.</p>

<h4 id="clusterlogforwarder">ClusterLogForwarder</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">observability.openshift.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterLogForwarder</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">instance</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">serviceAccount</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">logcollector</span>

  <span class="na">inputs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">logspam-logs</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">application</span>
    <span class="na">application</span><span class="pi">:</span>
      <span class="na">includes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">namespace</span><span class="pi">:</span> <span class="s">logspam</span>

  <span class="na">outputs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-out</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">syslog</span>
    <span class="na">syslog</span><span class="pi">:</span>
      <span class="na">url</span><span class="pi">:</span> <span class="s">tcp://rsyslog-service.syslog-server.svc.cluster.local:1514</span>
      <span class="na">rfc</span><span class="pi">:</span> <span class="s">RFC5424</span>
      <span class="na">enrichment</span><span class="pi">:</span> <span class="s">KubernetesMinimal</span>

  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">loki-out</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">lokiStack</span>
    <span class="na">lokiStack</span><span class="pi">:</span>
      <span class="na">target</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">logging-loki</span>
        <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
      <span class="na">authentication</span><span class="pi">:</span>
        <span class="na">token</span><span class="pi">:</span>
          <span class="na">from</span><span class="pi">:</span> <span class="s">serviceAccount</span>
    <span class="na">tls</span><span class="pi">:</span>
      <span class="na">ca</span><span class="pi">:</span>
        <span class="na">configMapName</span><span class="pi">:</span> <span class="s">openshift-service-ca.crt</span>
        <span class="na">key</span><span class="pi">:</span> <span class="s">service-ca.crt</span>

  <span class="na">pipelines</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">app-to-all</span>
    <span class="na">inputRefs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">logspam-logs</span>
    <span class="na">outputRefs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">syslog-out</span>
    <span class="pi">-</span> <span class="s">loki-out</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">lokiStack</code> output type references the LokiStack by name and namespace rather than a URL — the Vector collector handles service discovery, authentication, and TLS internally. <code class="language-plaintext highlighter-rouge">authentication.token.from: serviceAccount</code> tells the collector to use the <code class="language-plaintext highlighter-rouge">logcollector</code> ServiceAccount token when writing to Loki. The <code class="language-plaintext highlighter-rouge">tls.ca</code> block references the cluster’s internal CA bundle that OpenShift injects into every namespace automatically.</p>

<h4 id="clusterrolebinding">ClusterRoleBinding</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterRoleBinding</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logcollector-write-application-logs</span>
<span class="na">roleRef</span><span class="pi">:</span>
  <span class="na">apiGroup</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io</span>
  <span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterRole</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">cluster-logging-write-application-logs</span>
<span class="na">subjects</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">kind</span><span class="pi">:</span> <span class="s">ServiceAccount</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logcollector</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">logcollector</code> ServiceAccount already has <code class="language-plaintext highlighter-rouge">collect-application-logs</code> from the previous post, which covers reading logs. Writing to LokiStack requires the separate <code class="language-plaintext highlighter-rouge">cluster-logging-write-application-logs</code> ClusterRole, installed by the Logging Operator.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 5-clf-dual-output.yaml
</code></pre></div></div>

<p>Confirm the CLF reconciled cleanly and both outputs are healthy:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get clusterlogforwarder instance <span class="nt">-n</span> openshift-logging <span class="se">\</span>
  <span class="nt">-o</span> json | jq <span class="s1">'.status'</span>
</code></pre></div></div>

<p>Verify syslog is still receiving logs from the second output:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc logs <span class="nt">-n</span> syslog-server <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span>rsyslog-server <span class="nt">--tail</span><span class="o">=</span>5
</code></pre></div></div>

<p>You should see recent structured syslog entries from the logspam namespace still arriving as before.</p>

<p>Then confirm Loki is ingesting. Vector does not log individual Loki requests at its default log level, so the reliable signal is querying the Loki labels API directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">LOKI_ROUTE</span><span class="o">=</span><span class="si">$(</span>oc get route logging-loki <span class="nt">-n</span> openshift-logging <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.spec.host}'</span><span class="si">)</span>
<span class="nv">TOKEN</span><span class="o">=</span><span class="si">$(</span>oc <span class="nb">whoami</span> <span class="nt">-t</span><span class="si">)</span>
curl <span class="nt">-sk</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> <span class="se">\</span>
  <span class="s2">"https://</span><span class="k">${</span><span class="nv">LOKI_ROUTE</span><span class="k">}</span><span class="s2">/api/logs/v1/application/loki/api/v1/labels"</span> | jq <span class="nb">.</span>
</code></pre></div></div>

<p>Expected output:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"success"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"k8s_container_name"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"k8s_namespace_name"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"k8s_node_name"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"k8s_pod_name"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"log_type"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"openshift_log_type"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">data</code> is an empty array, the collector has not pushed anything yet — wait 30 seconds and retry.</p>

<hr />

<h3 id="step-5-query-logs-in-grafana">Step 5: Query Logs in Grafana</h3>

<p>📄 <a href="/posts/visualizing-openshift-logs-grafana/6-get-grafana-url.sh">6-get-grafana-url.sh</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash 6-get-grafana-url.sh
</code></pre></div></div>

<p>The Grafana Operator creates an admin credentials secret automatically named <code class="language-plaintext highlighter-rouge">logging-grafana-admin-credentials</code>. The script reads the route and pulls the username and password from that secret directly:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>========================================
  Grafana Login Details
========================================
URL:      https://logging-grafana-route-grafana-logging.apps.example.com
Username: admin
Password: &lt;generated-password&gt;
========================================
</code></pre></div></div>

<p>Open the URL in a browser and log in. Use the <strong>Explore</strong> view from the left sidebar (the compass icon) — do not use the <strong>Explore Logs</strong> app. The Explore Logs app calls a <code class="language-plaintext highlighter-rouge">detected_labels</code> endpoint that the LokiStack gateway does not proxy, which causes 404 errors. The standard Explore view works correctly with LokiStack.</p>

<p>Select the <strong>Loki</strong> datasource and query:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{kubernetes_namespace_name="logspam"}
</code></pre></div></div>

<p>You will see the same log stream from the <code class="language-plaintext highlighter-rouge">logspam</code> generator — timestamped, color-coded by log level, and filterable by label or field.</p>

<p><img src="/posts/visualizing-openshift-logs-grafana/grafana-explore-logspam-logs.png" alt="Grafana Explore showing logspam logs from LokiStack" /></p>

<p>The syslog server is receiving the same stream in parallel. Both outputs are driven by the same <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> pipeline — add more outputs to fan logs to additional destinations without touching your workloads.</p>

<hr />

<h2 id="references">References</h2>

<ul>
  <li><a href="https://docs.redhat.com/en/documentation/red_hat_openshift_logging/6.5/html/loki_operator/">Red Hat Loki Operator docs</a></li>
  <li><a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/logging/logging-6x-lokistack">OCP Docs: Configuring a LokiStack</a></li>
  <li><a href="https://docs.redhat.com/en/documentation/red_hat_openshift_logging/6.2/html/log_collection_and_forwarding/about-log-collection-and-forwarding">OCP Logging 6.x Docs: Log forwarding</a></li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[The previous post covered forwarding OpenShift logs to an external syslog server using the Logging Operator and a ClusterLogForwarder. That handles the durability problem — logs leave the cluster and land somewhere they can outlive it. This post adds the other half: querying and visualizing those same logs interactively inside the cluster using LokiStack and Grafana. The ClusterLogForwarder from the previous post gets a second output added, so syslog forwarding keeps working and Loki gets the same stream alongside it.]]></summary></entry><entry><title type="html">Why you should not install Operators in common namespace such as openshift-operators</title><link href="/2026/04/08/openshift-operators-dedicated-namespaces.html" rel="alternate" type="text/html" title="Why you should not install Operators in common namespace such as openshift-operators" /><published>2026-04-08T00:00:00+00:00</published><updated>2026-04-08T00:00:00+00:00</updated><id>/2026/04/08/openshift-operators-dedicated-namespaces</id><content type="html" xml:base="/2026/04/08/openshift-operators-dedicated-namespaces.html"><![CDATA[<p>OpenShift ships with an <code class="language-plaintext highlighter-rouge">openshift-operators</code> namespace that looks like the obvious place to put operators. The OperatorHub UI defaults to it for any operator with “All Namespaces” scope. This is a quick why and how-to for understanding what actually happens inside that namespace — and why it quietly removes your ability to upgrade operators on your own terms.</p>

<h2 id="why-this-matters">Why This Matters</h2>

<p>When OLM installs an operator, it creates an <code class="language-plaintext highlighter-rouge">InstallPlan</code> in the same namespace as the <code class="language-plaintext highlighter-rouge">Subscription</code>. That InstallPlan is OLM’s resolved list of everything that needs to be installed — the CSVs, their dependencies, and the order they go in. The <code class="language-plaintext highlighter-rouge">approved</code> field is the gate: <code class="language-plaintext highlighter-rouge">Automatic</code> means OLM proceeds immediately, <code class="language-plaintext highlighter-rouge">Manual</code> means nothing moves until a human approves it.</p>

<p>The catch is that OLM resolves dependencies at the namespace level, not the subscription level. Every operator in a namespace gets pulled into the same InstallPlan. So when any operator has an available upgrade, OLM creates a single plan covering all operators in that namespace that can be upgraded — not just the one you care about.</p>

<p>That has two consequences worth sitting with. First, if you want to upgrade one operator, you are also upgrading every other operator in that namespace that has an available update. There is no mechanism to split them. Second, if any one subscription in the namespace is set to <code class="language-plaintext highlighter-rouge">Manual</code> approval, the entire plan waits for human sign-off — including operators whose subscriptions say <code class="language-plaintext highlighter-rouge">Automatic</code>. The UI will show those as Automatic. The behavior is Manual. Red Hat’s own support documentation calls this out directly.</p>

<p>This is not a bug. It is the documented behavior of the current OLM API. The problem is that it accumulates silently because the OperatorHub UI never tells you it is happening.</p>

<hr />

<h2 id="the-steps">The Steps</h2>

<ol>
  <li>Inspect the <code class="language-plaintext highlighter-rouge">openshift-operators</code> namespace to see how many subscriptions are sharing it</li>
  <li>Examine an InstallPlan to confirm that a single plan spans multiple operators</li>
</ol>

<hr />

<h2 id="how-to-do-it">How To Do It</h2>

<h3 id="step-1-see-what-is-sharing-the-namespace">Step 1: See what is sharing the namespace</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get subscriptions <span class="nt">-n</span> openshift-operators
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME                                                                PACKAGE                           SOURCE             CHANNEL
devworkspace-operator-fast-redhat-operators-openshift-marketplace   devworkspace-operator             redhat-operators   fast
loki-operator                                                       loki-operator                     redhat-operators   stable-6.5
openshift-pipelines-operator-rh                                     openshift-pipelines-operator-rh   redhat-operators   latest
web-terminal                                                        web-terminal                      redhat-operators   fast
</code></pre></div></div>

<p>Four operators in the same namespace. Each one is now part of the same dependency resolution pool.</p>

<hr />

<h3 id="step-2-inspect-an-installplan-to-see-the-bundling">Step 2: Inspect an InstallPlan to see the bundling</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get installplan <span class="nt">-n</span> openshift-operators <span class="nt">-o</span> wide
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME            CSV                                       APPROVAL   APPROVED
install-6zbg9   openshift-pipelines-operator-rh.v1.21.1   Manual     true
install-f5jr2   loki-operator.v6.5.0                      Manual     true
install-lszl8   web-terminal.v1.15.0                      Manual     true
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">wide</code> output only shows the first CSV in each plan. Pull the full list for one:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get installplan install-lszl8 <span class="nt">-n</span> openshift-operators <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.spec.clusterServiceVersionNames}'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>["web-terminal.v1.15.0","devworkspace-operator.v0.40.0"]
</code></pre></div></div>

<p>That is the problem. <code class="language-plaintext highlighter-rouge">install-lszl8</code> looks like a Web Terminal plan, but it includes <code class="language-plaintext highlighter-rouge">devworkspace-operator</code> too. Approving it upgrades both. There is no way to approve one without the other while they share a namespace.</p>

<p>To approve:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc patch installplan install-lszl8 <span class="nt">-n</span> openshift-operators <span class="se">\</span>
  <span class="nt">--type</span> merge <span class="se">\</span>
  <span class="nt">--patch</span> <span class="s1">'{"spec":{"approved":true}}'</span>
</code></pre></div></div>

<p>Both operators upgrade simultaneously.</p>

<hr />

<p>The fix is to give each operator its own namespace and its own <code class="language-plaintext highlighter-rouge">OperatorGroup</code>. InstallPlans are scoped to a namespace, so operators in separate namespaces can never be bundled together. The <code class="language-plaintext highlighter-rouge">openshift-operators</code> namespace is still useful for quick experiments or operators you do not need to manage long-term. But for anything in production, or anything you need upgrade control over, a dedicated namespace is the right call.</p>

<hr />

<h2 id="references">References</h2>

<ul>
  <li><a href="https://access.redhat.com/solutions/6389681">Red Hat Solution: InstallPlans referencing more than one operator in openshift-operators namespace</a></li>
  <li><a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/operators/understanding-operators#olm-operatorgroups-concept_olm-understanding-operatorgroups">OCP Docs: Operator Lifecycle Manager concepts — OperatorGroup</a></li>
  <li><a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/operators/understanding-operators#olm-understanding-operatorhub">OCP Docs: Understanding OperatorHub</a></li>
  <li><a href="/2026/04/06/exploring-openshift-operators.html">How to Find, Install, and Explore an OpenShift Operator from the CLI</a></li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[OpenShift ships with an openshift-operators namespace that looks like the obvious place to put operators. The OperatorHub UI defaults to it for any operator with “All Namespaces” scope. This is a quick why and how-to for understanding what actually happens inside that namespace — and why it quietly removes your ability to upgrade operators on your own terms.]]></summary></entry><entry><title type="html">How to Find, Install, and Explore an OpenShift Operator from the CLI</title><link href="/2026/04/06/exploring-openshift-operators.html" rel="alternate" type="text/html" title="How to Find, Install, and Explore an OpenShift Operator from the CLI" /><published>2026-04-06T00:00:00+00:00</published><updated>2026-04-06T00:00:00+00:00</updated><id>/2026/04/06/exploring-openshift-operators</id><content type="html" xml:base="/2026/04/06/exploring-openshift-operators.html"><![CDATA[<p>Documentation gets you most of the way there, but operators move fast. Channels get renamed, APIs change between major versions, CRD fields appear and disappear. This is a quick why and how-to for going from zero to a fully installed operator using only <code class="language-plaintext highlighter-rouge">oc</code> — finding what’s available, picking the right channel, applying the subscription, and then walking the CRDs it registers so you understand what you’ve actually installed before you write a single manifest.</p>

<h2 id="why-this-matters">Why This Matters</h2>

<p>The gap between “what the docs say” and “what’s actually on your cluster” is where most operator frustration happens. You apply a manifest from a tutorial, get a validation error or a subscription that never resolves, and you’re not sure if the problem is your YAML, your cluster version, or an operator that changed its API.</p>

<p>Most people jump straight to the UI or copy a Subscription from a blog post without checking whether that channel even exists on their cluster. Then they wait five minutes wondering why nothing is happening. The CLI gives you the tools to do this right from the start — find the operator, confirm the channel, install it properly, and understand what it registered before you write a single CR.</p>

<p>This is worth slowing down to learn. Once you know how to go from catalog to installed operator entirely in <code class="language-plaintext highlighter-rouge">oc</code>, you can do it for any operator on any cluster without hunting for the right tutorial.</p>

<hr />

<h2 id="the-steps">The Steps</h2>

<ol>
  <li>Find what operators are available and where they come from</li>
  <li>Query the OperatorHub catalog to find available channels before subscribing</li>
  <li>Create the namespace, OperatorGroup, and Subscription to install the operator</li>
  <li>Wait for the CSV to confirm a successful install</li>
  <li>Find what CRDs the operator actually registered</li>
  <li>Use <code class="language-plaintext highlighter-rouge">oc explain</code> to walk the CRD spec interactively</li>
</ol>

<hr />

<h2 id="how-to-do-it">How To Do It</h2>

<h3 id="step-1-find-what-operators-are-available">Step 1: Find What Operators Are Available</h3>

<p>Before you know which operator you want, it helps to understand where they come from. OpenShift pulls its operator catalog from <code class="language-plaintext highlighter-rouge">CatalogSource</code> objects — these are the upstream feeds that populate OperatorHub. By default, a fresh cluster has three:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get catalogsource <span class="nt">-n</span> openshift-marketplace
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME                  DISPLAY               TYPE   PUBLISHER   AGE
certified-operators   Certified Operators   grpc   Red Hat     10d
community-operators   Community Operators   grpc   Red Hat     10d
redhat-operators      Red Hat Operators     grpc   Red Hat     10d
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">redhat-operators</code> is the source for first-party Red Hat products like Logging, OpenShift Data Foundation, and ACM. <code class="language-plaintext highlighter-rouge">certified-operators</code> covers ISV software that’s gone through Red Hat’s certification process. <code class="language-plaintext highlighter-rouge">community-operators</code> is OperatorHub.io content — open source, community-maintained.</p>

<p>Every operator in those catalogs becomes a <code class="language-plaintext highlighter-rouge">PackageManifest</code> on your cluster. To list everything available:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest <span class="nt">-n</span> openshift-marketplace
</code></pre></div></div>

<p>That list is long. To filter it down to what you’re looking for, you can grep on name:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest <span class="nt">-n</span> openshift-marketplace | <span class="nb">grep</span> <span class="nt">-i</span> logging
</code></pre></div></div>

<p>Or filter by catalog source using a label selector:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest <span class="nt">-n</span> openshift-marketplace <span class="se">\</span>
  <span class="nt">-l</span> <span class="nv">catalog</span><span class="o">=</span>redhat-operators | <span class="nb">grep</span> <span class="nt">-i</span> logging
</code></pre></div></div>

<p>If you want to search from the web console instead, go to <strong>Operators → OperatorHub</strong>. There’s a search bar at the top that filters by name as you type. The left panel lets you narrow by source (Red Hat, Certified, Community), category (Logging &amp; Tracing, Storage, Security, etc.), and capability level. It’s the fastest way to browse if you don’t know the exact package name yet — the CLI approach is more reliable when you already know what you want and need scriptable output.</p>

<hr />

<h3 id="step-2-find-the-right-channel-before-you-subscribe">Step 2: Find the Right Channel Before You Subscribe</h3>

<p>Operator subscriptions require a <code class="language-plaintext highlighter-rouge">channel</code> field. Documentation often shows <code class="language-plaintext highlighter-rouge">stable</code>, but many operators publish versioned channels like <code class="language-plaintext highlighter-rouge">stable-6.5</code> and don’t maintain a generic alias. Subscribing to a non-existent channel fails silently — the subscription sits unresolved.</p>

<p>Query the OperatorHub catalog first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest cluster-logging <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.channels[*].name}'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stable-6.2 stable-6.3 stable-6.4 stable-6.5
</code></pre></div></div>

<p>Then check which channel the operator recommends as default:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest cluster-logging <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.defaultChannel}'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stable-6.5
</code></pre></div></div>

<p>Use that value in your <code class="language-plaintext highlighter-rouge">Subscription</code>. If you want to pin to a specific version rather than float to the latest, use an exact channel like <code class="language-plaintext highlighter-rouge">stable-6.4</code>.</p>

<p>You can also check what CSV (operator version) each channel points to before subscribing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest cluster-logging <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{range .status.channels[*]}{.name}{"\t"}{.currentCSV}{"\n"}{end}'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>stable-6.2    cluster-logging.v6.2.0
stable-6.3    cluster-logging.v6.3.0
stable-6.4    cluster-logging.v6.4.0
stable-6.5    cluster-logging.v6.5.0
</code></pre></div></div>

<hr />

<h3 id="step-3-create-the-namespace-operatorgroup-and-subscription">Step 3: Create the Namespace, OperatorGroup, and Subscription</h3>

<p>Three resources are required to install an operator via OLM from the CLI. None of them are created by OperatorHub automatically when you’re working in the terminal.</p>

<p><strong>Namespace</strong> — operators install into a specific namespace. For Logging, that’s <code class="language-plaintext highlighter-rouge">openshift-logging</code>. Create it first if it doesn’t exist:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc create namespace openshift-logging
</code></pre></div></div>

<p><strong>OperatorGroup</strong> — tells OLM which namespaces the operator is allowed to watch. A single-namespace OperatorGroup scopes the operator to <code class="language-plaintext highlighter-rouge">openshift-logging</code> only, which is correct for Logging. Without an OperatorGroup in the namespace, the Subscription will stall:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh"> | oc apply -f -
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
  name: openshift-logging
  namespace: openshift-logging
spec:
  targetNamespaces:
  - openshift-logging
</span><span class="no">EOF
</span></code></pre></div></div>

<p><strong>Subscription</strong> — this is what actually triggers OLM to pull and install the operator. It references the catalog source (<code class="language-plaintext highlighter-rouge">redhat-operators</code>), the package name (<code class="language-plaintext highlighter-rouge">cluster-logging</code>), and the channel you identified in Step 1:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh"> | oc apply -f -
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
  name: cluster-logging
  namespace: openshift-logging
spec:
  channel: stable-6.5
  installPlanApproval: Automatic
  name: cluster-logging
  source: redhat-operators
  sourceNamespace: openshift-marketplace
</span><span class="no">EOF
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">installPlanApproval: Automatic</code> means OLM will approve and execute the install without you having to manually approve an <code class="language-plaintext highlighter-rouge">InstallPlan</code>. Use <code class="language-plaintext highlighter-rouge">Manual</code> if you want to inspect what will be installed before committing — useful in production environments where you want to control when upgrades happen.</p>

<p>Once the Subscription is applied, OLM creates an <code class="language-plaintext highlighter-rouge">InstallPlan</code> and begins pulling the operator. You can watch its progress:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get installplan <span class="nt">-n</span> openshift-logging
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME            CSV                       APPROVAL    APPROVED
install-abc12   cluster-logging.v6.5.0    Automatic   true
</code></pre></div></div>

<hr />

<h3 id="step-4-wait-for-the-csv-not-just-the-pod">Step 4: Wait for the CSV, Not Just the Pod</h3>

<p>After applying a Subscription, the OLM installs the operator as a <code class="language-plaintext highlighter-rouge">ClusterServiceVersion</code>. The CSV is the real indicator of a successful install — a pod being <code class="language-plaintext highlighter-rouge">Running</code> just means the container started, not that OLM finished reconciling.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get csv <span class="nt">-n</span> openshift-logging <span class="nt">-w</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME                       DISPLAY                     VERSION   PHASE
cluster-logging.v6.5.0     Red Hat OpenShift Logging   6.5.0     Installing
cluster-logging.v6.5.0     Red Hat OpenShift Logging   6.5.0     Succeeded
</code></pre></div></div>

<p>Don’t apply CRs until the CSV reaches <code class="language-plaintext highlighter-rouge">Succeeded</code>. If it stalls at <code class="language-plaintext highlighter-rouge">Installing</code>, check <code class="language-plaintext highlighter-rouge">oc get installplan -n &lt;namespace&gt;</code> for approval blocks or dependency resolution failures.</p>

<hr />

<h3 id="step-5-find-what-crds-the-operator-registered">Step 5: Find What CRDs the Operator Registered</h3>

<p>Once the CSV is <code class="language-plaintext highlighter-rouge">Succeeded</code>, the operator has registered its CRDs into the cluster API. Don’t assume you know their names or API groups — operators change these across major versions. In Logging’s case, 5.x used <code class="language-plaintext highlighter-rouge">logging.openshift.io</code> and 6.x moved to <code class="language-plaintext highlighter-rouge">observability.openshift.io</code>.</p>

<p>The most reliable way to see exactly what the operator registered is to ask the CSV directly. It has an explicit <code class="language-plaintext highlighter-rouge">owned</code> list of every CRD it manages:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get csv cluster-logging.v6.5.0 <span class="nt">-n</span> openshift-logging <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{range .spec.customresourcedefinitions.owned[*]}{.name}{"\t"}{.version}{"\t"}{.kind}{"\n"}{end}'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>clusterlogforwarders.observability.openshift.io   v1   ClusterLogForwarder
logfilemetricexporters.logging.openshift.io       v1alpha1   LogFileMetricExporter
</code></pre></div></div>

<p>This is authoritative — it’s the same list OLM used to register the CRDs, so it works even when the API group name has nothing obvious to do with the operator. If you don’t know the exact CSV name, grab it first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get csv <span class="nt">-n</span> openshift-logging <span class="nt">-o</span> name
</code></pre></div></div>

<p>If you want a quick scan across all CRDs on the cluster without knowing the CSV name, grep is a useful secondary option:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get crd | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s2">"logging|observ"</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>clusterlogforwarders.observability.openshift.io   2026-04-01T20:03:34Z
logfilemetricexporters.logging.openshift.io       2026-04-01T20:03:34Z
</code></pre></div></div>

<p>Either way, this tells you two things immediately: the API group changed, and <code class="language-plaintext highlighter-rouge">ClusterLogging</code> is gone entirely in this version. If you’d written <code class="language-plaintext highlighter-rouge">apiVersion: logging.openshift.io/v1</code> in your manifest, it would have been rejected.</p>

<hr />

<h3 id="step-6-explore-the-spec-with-oc-explain">Step 6: Explore the Spec with <code class="language-plaintext highlighter-rouge">oc explain</code></h3>

<p><code class="language-plaintext highlighter-rouge">oc explain</code> is the most underused tool for working with operators. It reads the CRD’s API schema and prints field descriptions, types, and required markers directly in your terminal. You don’t need docs, you don’t need examples — you can walk the entire spec yourself.</p>

<p>Start at the top level:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc explain clusterlogforwarders.observability.openshift.io.spec
</code></pre></div></div>

<p>Go deeper with <code class="language-plaintext highlighter-rouge">--recursive</code> to see the full tree:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc explain clusterlogforwarders.observability.openshift.io.spec <span class="nt">--recursive</span>
</code></pre></div></div>

<p>Drill into a specific sub-field to get descriptions and enum values:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc explain clusterlogforwarders.observability.openshift.io.spec.outputs.syslog
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FIELDS:
  appName     &lt;string&gt;
  enrichment  &lt;string&gt;
  enum: None, KubernetesMinimal
  facility    &lt;string&gt;
  msgId       &lt;string&gt;
  payloadKey  &lt;string&gt;
  procId      &lt;string&gt;
  rfc         &lt;string&gt; -required-
  enum: RFC3164, RFC5424
  severity    &lt;string&gt;
  tuning      &lt;Object&gt;
    deliveryMode  &lt;string&gt;
    enum: AtLeastOnce, AtMostOnce
  url         &lt;string&gt; -required-
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">-required-</code> markers tell you exactly which fields you can’t skip. The <code class="language-plaintext highlighter-rouge">enum:</code> lines tell you the valid values. This is more reliable than docs for the version actually installed on your cluster.</p>

<p>Use the same approach to discover the <code class="language-plaintext highlighter-rouge">serviceAccount</code> requirement — something that often isn’t obvious until you get a validation error:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc explain clusterlogforwarders.observability.openshift.io.spec.serviceAccount
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FIELDS:
  name  &lt;string&gt; -required-
</code></pre></div></div>

<p>And to understand how the v6 input filtering changed from <code class="language-plaintext highlighter-rouge">application.namespaces</code> to <code class="language-plaintext highlighter-rouge">application.includes</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc explain clusterlogforwarders.observability.openshift.io.spec.inputs.application <span class="nt">--recursive</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FIELDS:
  excludes  &lt;[]Object&gt;
    container   &lt;string&gt;
    namespace   &lt;string&gt;
  includes  &lt;[]Object&gt;
    container   &lt;string&gt;
    namespace   &lt;string&gt;
  ...
</code></pre></div></div>

<p>If you prefer a visual interface, the OpenShift web console has an API Explorer under <strong>Home → API Explorer</strong>. Search for the resource by kind, select it, and the Schema tab gives you the same field tree with descriptions — useful for browsing without constructing <code class="language-plaintext highlighter-rouge">oc explain</code> paths by hand.</p>

<hr />

<h2 id="putting-it-together">Putting It Together</h2>

<p>The pattern here applies to any operator, not just Logging:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">oc get packagemanifest -n openshift-marketplace | grep &lt;keyword&gt;</code> — find available operators and which catalog they come from</li>
  <li><code class="language-plaintext highlighter-rouge">oc get packagemanifest &lt;name&gt;</code> — find channels and the default before subscribing</li>
  <li>Apply a <code class="language-plaintext highlighter-rouge">Namespace</code>, <code class="language-plaintext highlighter-rouge">OperatorGroup</code>, and <code class="language-plaintext highlighter-rouge">Subscription</code> — the three resources OLM needs to install the operator</li>
  <li><code class="language-plaintext highlighter-rouge">oc get csv -n &lt;namespace&gt; -w</code> — wait for <code class="language-plaintext highlighter-rouge">Succeeded</code>, not just a running pod</li>
  <li><code class="language-plaintext highlighter-rouge">oc get csv &lt;name&gt; -o jsonpath='{range .spec.customresourcedefinitions.owned[*]}{.name}{"\n"}{end}'</code> — confirm the exact CRDs the operator registered</li>
  <li><code class="language-plaintext highlighter-rouge">oc explain &lt;crd&gt;.spec --recursive</code> — walk the full spec without leaving the terminal</li>
</ol>

<p>None of this requires external access or docs that match your exact version. Everything you need is already in the cluster.</p>

<hr />

<h2 id="references">References</h2>

<ul>
  <li><a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/operators/administrator-tasks#olm-adding-operators-to-a-cluster">OCP Docs: Adding operators to a cluster</a></li>
  <li><a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/cli_tools/openshift-cli-oc#oc-explain">OCP Docs: oc explain</a></li>
  <li><a href="https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation">Kubernetes Docs: Understanding CRD validation schemas</a></li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[Documentation gets you most of the way there, but operators move fast. Channels get renamed, APIs change between major versions, CRD fields appear and disappear. This is a quick why and how-to for going from zero to a fully installed operator using only oc — finding what’s available, picking the right channel, applying the subscription, and then walking the CRDs it registers so you understand what you’ve actually installed before you write a single manifest.]]></summary></entry><entry><title type="html">Shipping OpenShift Logs to an External Syslog Server</title><link href="/2026/04/03/forwarding-openshift-logs-syslog.html" rel="alternate" type="text/html" title="Shipping OpenShift Logs to an External Syslog Server" /><published>2026-04-03T00:00:00+00:00</published><updated>2026-04-03T00:00:00+00:00</updated><id>/2026/04/03/forwarding-openshift-logs-syslog</id><content type="html" xml:base="/2026/04/03/forwarding-openshift-logs-syslog.html"><![CDATA[<p>OpenShift ships with a full logging stack — collect, store, and view logs all within the cluster using the Logging Operator and LokiStack. That’s a great starting point. But at some point you need logs to outlive the cluster, land in a centralized system that aggregates across environments, or feed into compliance and audit tooling that expects syslog. This is a quick why and how-to for configuring the OpenShift Logging Operator to forward logs to an external syslog server.</p>

<h2 id="why-this-matters">Why This Matters</h2>

<p>Most teams don’t think hard about log routing until something breaks or audit season arrives. The default LokiStack setup stores logs inside the cluster, which is excellent for day-to-day debugging — but logs that live and die with the cluster aren’t much use after a node failure, a cluster rebuild, or a security incident you’re investigating three weeks later.</p>

<p>Syslog has been the lingua franca of centralized log aggregation for decades. Every SIEM, every compliance platform, every mature log management system speaks it. When you configure OpenShift to forward to syslog, you’re plugging into that ecosystem without changing how your applications write logs. Your workloads log to stdout, the platform handles the rest.</p>

<p>The other thing worth slowing down to consider: the <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> can send to multiple outputs simultaneously. You don’t lose your local LokiStack — you add syslog alongside it. Set the routing once, and the platform fans it out.</p>

<hr />

<h2 id="the-steps">The Steps</h2>

<ol>
  <li>Create a <code class="language-plaintext highlighter-rouge">logspam</code> namespace and deploy a <code class="language-plaintext highlighter-rouge">log-generator</code> Deployment running a UBI 9 container that emits log messages at random levels and intervals</li>
  <li>Build and deploy an rsyslog server in its own namespace, exposed externally via NodePort</li>
  <li>Install the OpenShift Logging Operator from OperatorHub</li>
  <li>Create a <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> pointing at the syslog external endpoint</li>
  <li><strong>Bonus:</strong> Install Loki and Grafana, then replace the Step 4 CLF with a dual-output version that sends logs to both syslog and LokiStack simultaneously</li>
</ol>

<hr />

<h2 id="how-to-do-it">How To Do It</h2>

<h3 id="step-1-the-log-generator">Step 1: The Log Generator</h3>

<p>The log generator is a UBI 9 container running a shell loop that emits messages at all four log levels — INFO, WARN, ERROR, and DEBUG — at random intervals. It gives you real, varied log output to forward and inspect.</p>

<p>📄 <a href="/posts/forwarding-openshift-logs-syslog/1-logspam.yaml">1-logspam.yaml</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 1-logspam.yaml
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Namespace</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logspam</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">log-generator</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">logspam</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">log-generator</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">app</span><span class="pi">:</span> <span class="s">log-generator</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">log-generator</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">registry.access.redhat.com/ubi9/ubi:latest</span>
        <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">/bin/bash"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">-c"</span><span class="pi">]</span>
        <span class="na">args</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="pi">|</span>
          <span class="s">LEVELS=("INFO" "WARN" "ERROR" "DEBUG")</span>
          <span class="s">MESSAGES=(</span>
            <span class="s">"Application started successfully"</span>
            <span class="s">"High memory usage detected: 85%"</span>
            <span class="s">"Failed to connect to database - retrying"</span>
            <span class="s">"Processing request ID $((RANDOM % 100000))"</span>
            <span class="s">"Cache miss for key user-session-$((RANDOM % 999))"</span>
            <span class="s">"Successfully flushed write buffer to disk"</span>
            <span class="s">"Rate limit exceeded for client"</span>
            <span class="s">"Scheduled job completed"</span>
          <span class="s">)</span>
          <span class="s">while true; do</span>
            <span class="s">LEVEL=${LEVELS[$((RANDOM % 4))]}</span>
            <span class="s">MSG=${MESSAGES[$((RANDOM % 8))]}</span>
            <span class="s">echo "[${LEVEL}] ${MSG} at $(date -u +%Y-%m-%dT%H:%M:%SZ)"</span>
            <span class="s">sleep $((RANDOM % 5 + 1))</span>
          <span class="s">done</span>
</code></pre></div></div>

<p>Verify the pod is running before tailing logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get pods <span class="nt">-n</span> logspam <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span>log-generator
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME                             READY   STATUS    RESTARTS   AGE
log-generator-7d6f9b8c4-xk2pj   1/1     Running   0          30s
</code></pre></div></div>

<p>Then follow the output:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc logs <span class="nt">-n</span> logspam <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span>log-generator <span class="nt">-f</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[INFO] Application started successfully at 2026-04-01T10:23:41Z
[ERROR] Failed to connect to database - retrying at 2026-04-01T10:23:44Z
[WARN] High memory usage detected: 85% at 2026-04-01T10:23:46Z
</code></pre></div></div>

<hr />

<h3 id="step-2-the-syslog-server">Step 2: The Syslog Server</h3>

<p>The syslog server runs rsyslog inside a UBI 9 image built in-cluster. Building rsyslog into the image at build time — rather than installing it at runtime — means the container runs as a non-root user (<code class="language-plaintext highlighter-rouge">UID 1001</code>) with no elevated privileges needed. Port 514 is a privileged port that requires root to bind, so the server listens on <strong>port 1514</strong> instead, which any non-root process can bind freely.</p>

<p>📄 <a href="/posts/forwarding-openshift-logs-syslog/2-syslog-server.yaml">2-syslog-server.yaml</a></p>

<p>This single file creates every resource needed to build and run the syslog server — from the namespace through to the services. Here’s what’s in it and why each piece exists.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 2-syslog-server.yaml
</code></pre></div></div>

<h4 id="namespace">Namespace</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Namespace</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-server</span>
</code></pre></div></div>

<p>All syslog server resources land in the <code class="language-plaintext highlighter-rouge">syslog-server</code> namespace, keeping them isolated from the log generator and the logging operator.</p>

<h4 id="imagestream">ImageStream</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">image.openshift.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ImageStream</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-server</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">syslog-server</span>
</code></pre></div></div>

<p>An <code class="language-plaintext highlighter-rouge">ImageStream</code> is OpenShift’s internal image reference. The <code class="language-plaintext highlighter-rouge">BuildConfig</code> pushes the built image to this stream, and the <code class="language-plaintext highlighter-rouge">Deployment</code> pulls from it. This keeps everything inside the cluster’s internal registry — no external image pull needed.</p>

<h4 id="buildconfig">BuildConfig</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">build.openshift.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">BuildConfig</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-server</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">syslog-server</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">output</span><span class="pi">:</span>
    <span class="na">to</span><span class="pi">:</span>
      <span class="na">kind</span><span class="pi">:</span> <span class="s">ImageStreamTag</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-server:latest</span>
  <span class="na">source</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">Dockerfile</span>
    <span class="na">dockerfile</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">FROM registry.access.redhat.com/ubi9/ubi:latest</span>
      <span class="s">RUN dnf install -y rsyslog &amp;&amp; dnf clean all &amp;&amp; rm -rf /var/cache/dnf</span>
      <span class="s">USER 1001</span>
  <span class="na">strategy</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">Docker</span>
    <span class="na">dockerStrategy</span><span class="pi">:</span> <span class="pi">{}</span>
  <span class="na">triggers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">ConfigChange</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">BuildConfig</code> installs rsyslog into a UBI 9 base image at build time and drops to <code class="language-plaintext highlighter-rouge">UID 1001</code> before the image is committed. The <code class="language-plaintext highlighter-rouge">ConfigChange</code> trigger fires the build automatically when the <code class="language-plaintext highlighter-rouge">BuildConfig</code> is first created. The result is pushed to the <code class="language-plaintext highlighter-rouge">rsyslog-server</code> ImageStream.</p>

<p>Wait for the build to complete before the Deployment pod can start (about 60 seconds):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get builds <span class="nt">-n</span> syslog-server <span class="nt">-w</span>
</code></pre></div></div>

<h4 id="configmap-rsyslog-config">ConfigMap: rsyslog config</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ConfigMap</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-config</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">syslog-server</span>
<span class="na">data</span><span class="pi">:</span>
  <span class="na">rsyslog.conf</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">global(workDirectory="/tmp/rsyslog")</span>

    <span class="s">module(load="imudp")</span>
    <span class="s">input(type="imudp" port="1514")</span>

    <span class="s">module(load="imtcp")</span>
    <span class="s">input(type="imtcp" port="1514")</span>

    <span class="s">$template RemoteFormat,"%TIMESTAMP:::date-rfc3339% [%HOSTNAME%] %syslogtag%%msg%\n"</span>
    <span class="s">*.* /tmp/syslog/syslog.log;RemoteFormat</span>
</code></pre></div></div>

<p>The rsyslog configuration is mounted into the container at <code class="language-plaintext highlighter-rouge">/etc/rsyslog-custom/rsyslog.conf</code>. All paths are under <code class="language-plaintext highlighter-rouge">/tmp</code> — writable by any user — since the container runs without root. The server accepts both TCP and UDP on 1514 and writes every received message to <code class="language-plaintext highlighter-rouge">/tmp/syslog/syslog.log</code> using a timestamp-prefixed format.</p>

<h4 id="configmap-startup-script">ConfigMap: startup script</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ConfigMap</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-startup</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">syslog-server</span>
<span class="na">data</span><span class="pi">:</span>
  <span class="na">start.sh</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">#!/bin/bash</span>
    <span class="s">set -e</span>
    <span class="s">mkdir -p /tmp/rsyslog /tmp/syslog</span>
    <span class="s">touch /tmp/syslog/syslog.log</span>
    <span class="s">rsyslogd -n -i /tmp/rsyslog/rsyslogd.pid -f /etc/rsyslog-custom/rsyslog.conf &amp;</span>
    <span class="s">echo "rsyslog started, listening on TCP/UDP 1514"</span>
    <span class="s">exec tail -f /tmp/syslog/syslog.log</span>
</code></pre></div></div>

<p>The startup script is mounted at <code class="language-plaintext highlighter-rouge">/startup/start.sh</code> and runs as the container’s entrypoint. It creates the working directories, starts rsyslogd in the background with the custom config, then execs <code class="language-plaintext highlighter-rouge">tail -f</code> on the log file so the container’s stdout streams received syslog messages — making them visible via <code class="language-plaintext highlighter-rouge">oc logs</code>.</p>

<h4 id="deployment">Deployment</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">apps/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-server</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">syslog-server</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">rsyslog-server</span>
  <span class="na">template</span><span class="pi">:</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">labels</span><span class="pi">:</span>
        <span class="na">app</span><span class="pi">:</span> <span class="s">rsyslog-server</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">containers</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">image-registry.openshift-image-registry.svc:5000/syslog-server/rsyslog-server:latest</span>
        <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">/bin/bash"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">/startup/start.sh"</span><span class="pi">]</span>
        <span class="na">ports</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-tcp</span>
          <span class="na">containerPort</span><span class="pi">:</span> <span class="m">1514</span>
          <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-udp</span>
          <span class="na">containerPort</span><span class="pi">:</span> <span class="m">1514</span>
          <span class="na">protocol</span><span class="pi">:</span> <span class="s">UDP</span>
        <span class="na">volumeMounts</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">startup</span>
          <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/startup</span>
        <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-config</span>
          <span class="na">mountPath</span><span class="pi">:</span> <span class="s">/etc/rsyslog-custom</span>
        <span class="na">resources</span><span class="pi">:</span>
          <span class="na">requests</span><span class="pi">:</span>
            <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">100m"</span>
            <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">128Mi"</span>
          <span class="na">limits</span><span class="pi">:</span>
            <span class="na">cpu</span><span class="pi">:</span> <span class="s2">"</span><span class="s">200m"</span>
            <span class="na">memory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">256Mi"</span>
      <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">startup</span>
        <span class="na">configMap</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-startup</span>
          <span class="na">defaultMode</span><span class="pi">:</span> <span class="m">0755</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-config</span>
        <span class="na">configMap</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-config</span>
</code></pre></div></div>

<p>The Deployment pulls the image from the internal registry, mounts both ConfigMaps, and exposes port 1514 on both TCP and UDP. The <code class="language-plaintext highlighter-rouge">defaultMode: 0755</code> on the startup ConfigMap volume ensures the script is executable when mounted.</p>

<h4 id="services">Services</h4>

<p>OpenShift Routes are HTTP/HTTPS only — they use HAProxy at layer 7 and can’t pass raw TCP syslog traffic. To reach the syslog server you need a <code class="language-plaintext highlighter-rouge">Service</code> instead. The YAML creates two:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># In-cluster access — used by the CLF when forwarding within the same cluster</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Service</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-service</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">syslog-server</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">rsyslog-server</span>
  <span class="na">ports</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-tcp</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">1514</span>
    <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
    <span class="na">targetPort</span><span class="pi">:</span> <span class="m">1514</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-udp</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">1514</span>
    <span class="na">protocol</span><span class="pi">:</span> <span class="s">UDP</span>
    <span class="na">targetPort</span><span class="pi">:</span> <span class="m">1514</span>
<span class="nn">---</span>
<span class="c1"># External access — fixed NodePort 31514</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Service</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">rsyslog-external</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">syslog-server</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">NodePort</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">rsyslog-server</span>
  <span class="na">ports</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-tcp</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">1514</span>
    <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
    <span class="na">targetPort</span><span class="pi">:</span> <span class="m">1514</span>
    <span class="na">nodePort</span><span class="pi">:</span> <span class="m">31514</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">ClusterIP</code> service (<code class="language-plaintext highlighter-rouge">rsyslog-service</code>) is what the <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> uses when forwarding logs within the same cluster — reachable at <code class="language-plaintext highlighter-rouge">rsyslog-service.syslog-server.svc.cluster.local:1514</code>. The <code class="language-plaintext highlighter-rouge">NodePort</code> service (<code class="language-plaintext highlighter-rouge">rsyslog-external</code>) exposes the server on port 31514 of any worker node for external access. A fixed <code class="language-plaintext highlighter-rouge">nodePort: 31514</code> makes it predictable — you know the port without having to look it up.</p>

<blockquote>
  <p>For production, prefer <code class="language-plaintext highlighter-rouge">type: LoadBalancer</code> on cloud platforms — it provisions an external load balancer with a stable IP rather than relying on individual node IPs. NodePort works well for on-premises and demo environments.</p>
</blockquote>

<hr />

<h3 id="step-3-install-the-openshift-logging-operator">Step 3: Install the OpenShift Logging Operator</h3>

<p>The Logging Operator is a Red Hat supported operator available from OperatorHub. As of Logging 6.x, it manages a Vector collector DaemonSet automatically when a <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> is created — no separate <code class="language-plaintext highlighter-rouge">ClusterLogging</code> instance required.</p>

<p>If you’re new to how operators work — what OLM is doing, how channels and subscriptions relate, or how to explore what an operator registers — the post <a href="/2026/04/06/exploring-openshift-operators.html">How to Find, Install, and Explore an OpenShift Operator from the CLI</a> covers all of that in detail.</p>

<p>📄 <a href="/posts/forwarding-openshift-logs-syslog/3-install-logging-operator.yaml">3-install-logging-operator.yaml</a></p>

<p>This file creates three resources that OLM needs to install an operator from the CLI:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 3-install-logging-operator.yaml
</code></pre></div></div>

<h4 id="namespace-1">Namespace</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Namespace</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">openshift-logging</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">openshift.io/node-selector</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">openshift.io/cluster-monitoring</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">openshift-logging</code> namespace is where the operator and all logging resources live. The <code class="language-plaintext highlighter-rouge">openshift.io/node-selector: ""</code> annotation clears any default node selector so the operator pods aren’t accidentally restricted to a subset of nodes. The <code class="language-plaintext highlighter-rouge">openshift.io/cluster-monitoring: "true"</code> label opts the namespace into cluster monitoring so the operator’s metrics are scraped automatically.</p>

<h4 id="operatorgroup">OperatorGroup</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">OperatorGroup</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">openshift-logging</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">targetNamespaces</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">openshift-logging</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">OperatorGroup</code> tells OLM which namespaces this operator is allowed to watch. Without one in the namespace, the Subscription will stall indefinitely. Scoping <code class="language-plaintext highlighter-rouge">targetNamespaces</code> to <code class="language-plaintext highlighter-rouge">openshift-logging</code> only gives the operator access to that namespace rather than cluster-wide.</p>

<h4 id="subscription">Subscription</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Subscription</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">cluster-logging</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">channel</span><span class="pi">:</span> <span class="s">stable-6.5</span>
  <span class="na">installPlanApproval</span><span class="pi">:</span> <span class="s">Automatic</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">cluster-logging</span>
  <span class="na">source</span><span class="pi">:</span> <span class="s">redhat-operators</span>
  <span class="na">sourceNamespace</span><span class="pi">:</span> <span class="s">openshift-marketplace</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Subscription</code> is what triggers OLM to pull and install the operator. It references the <code class="language-plaintext highlighter-rouge">redhat-operators</code> catalog, the <code class="language-plaintext highlighter-rouge">cluster-logging</code> package, and a specific channel. The Logging Operator does not publish a generic <code class="language-plaintext highlighter-rouge">stable</code> channel — check which channels are available on your cluster before applying:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get packagemanifest cluster-logging <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.channels[*].name}'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">installPlanApproval: Automatic</code> means OLM approves and executes the install without manual intervention. Use <code class="language-plaintext highlighter-rouge">Manual</code> in production if you want to inspect the <code class="language-plaintext highlighter-rouge">InstallPlan</code> before committing to an upgrade.</p>

<p>Wait for the operator CSV to reach <code class="language-plaintext highlighter-rouge">Succeeded</code> before moving on — a running pod is not sufficient confirmation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get csv <span class="nt">-n</span> openshift-logging <span class="nt">-w</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NAME                       DISPLAY                     VERSION   PHASE
cluster-logging.v6.5.0     Red Hat OpenShift Logging   6.5.0     Installing
cluster-logging.v6.5.0     Red Hat OpenShift Logging   6.5.0     Succeeded
</code></pre></div></div>

<hr />

<h3 id="step-4-configure-log-forwarding">Step 4: Configure Log Forwarding</h3>

<p>Logging 6.x introduced a new API group — <code class="language-plaintext highlighter-rouge">observability.openshift.io/v1</code> — and dropped the <code class="language-plaintext highlighter-rouge">ClusterLogging</code> resource entirely. The <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> now owns the full configuration: what to collect, where to send it, and which service account the collector runs as.</p>

<p>📄 <a href="/posts/forwarding-openshift-logs-syslog/4-cluster-log-forwarder.yaml">4-cluster-log-forwarder.yaml</a></p>

<p>This file creates three resources: a <code class="language-plaintext highlighter-rouge">ServiceAccount</code> for the collector, a <code class="language-plaintext highlighter-rouge">ClusterRoleBinding</code> that grants it read access to application logs, and the <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> itself.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 4-cluster-log-forwarder.yaml
</code></pre></div></div>

<h4 id="serviceaccount">ServiceAccount</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ServiceAccount</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logcollector</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
</code></pre></div></div>

<p>The Vector collector DaemonSet runs as this ServiceAccount. Logging 6.x requires an explicit <code class="language-plaintext highlighter-rouge">serviceAccount</code> reference in the <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> — the operator will not deploy the collector without it.</p>

<h4 id="clusterrolebinding">ClusterRoleBinding</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterRoleBinding</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logging-collector-application-logs</span>
<span class="na">roleRef</span><span class="pi">:</span>
  <span class="na">apiGroup</span><span class="pi">:</span> <span class="s">rbac.authorization.k8s.io</span>
  <span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterRole</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">collect-application-logs</span>
<span class="na">subjects</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">kind</span><span class="pi">:</span> <span class="s">ServiceAccount</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">logcollector</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">collect-application-logs</code> ClusterRole is installed by the Logging Operator and grants the collector read access to application pod logs across all namespaces. Without this binding the collector starts but cannot read any logs.</p>

<h4 id="clusterlogforwarder">ClusterLogForwarder</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">observability.openshift.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterLogForwarder</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">instance</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">openshift-logging</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">serviceAccount</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">logcollector</span>

  <span class="na">inputs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">logspam-logs</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">application</span>
    <span class="na">application</span><span class="pi">:</span>
      <span class="na">includes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">namespace</span><span class="pi">:</span> <span class="s">logspam</span>

  <span class="na">outputs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">syslog-out</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">syslog</span>
    <span class="na">syslog</span><span class="pi">:</span>
      <span class="na">url</span><span class="pi">:</span> <span class="s">tcp://rsyslog-service.syslog-server.svc.cluster.local:1514</span>
      <span class="na">rfc</span><span class="pi">:</span> <span class="s">RFC5424</span>
      <span class="na">facility</span><span class="pi">:</span> <span class="s">user</span>
      <span class="na">enrichment</span><span class="pi">:</span> <span class="s">KubernetesMinimal</span>

  <span class="na">pipelines</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">app-to-syslog</span>
    <span class="na">inputRefs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">logspam-logs</span>
    <span class="na">outputRefs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">syslog-out</span>
</code></pre></div></div>

<p>A few things worth understanding:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">inputs</code></strong> with <code class="language-plaintext highlighter-rouge">application.includes</code> scopes collection to the <code class="language-plaintext highlighter-rouge">logspam</code> namespace only. Remove this block and use the built-in <code class="language-plaintext highlighter-rouge">application</code> input ref to collect from all application namespaces.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">rfc: RFC5424</code></strong> is the structured syslog format — facility, severity, hostname, and app name as defined fields, useful if your downstream system parses structured data.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">enrichment: KubernetesMinimal</code></strong> attaches a reduced set of Kubernetes metadata to each log event — namespace, pod name, and container name only. The default includes the full pod annotations and labels, which can push individual syslog messages to several kilobytes. <code class="language-plaintext highlighter-rouge">KubernetesMinimal</code> keeps messages manageable without losing the context you need for troubleshooting.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">url</code></strong> uses the internal service created in Step 2 since the CLF and the syslog server are on the same cluster.</li>
</ul>

<h4 id="verify-the-pipeline">Verify the Pipeline</h4>

<p>Give the operator a moment to deploy the Vector DaemonSet, then confirm the CLF is ready:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get clusterlogforwarder instance <span class="nt">-n</span> openshift-logging <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.status.conditions}'</span> | jq <span class="nb">.</span>
</code></pre></div></div>

<p>Then watch the syslog server receive logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc logs <span class="nt">-n</span> syslog-server <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span>rsyslog-server <span class="nt">-f</span>
</code></pre></div></div>

<p>You’ll see RFC5424 entries arriving from the <code class="language-plaintext highlighter-rouge">logspam</code> pod with minimal Kubernetes metadata:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2026-04-01T20:08:00Z [control-plane] logspam_log-generator_log-generator {...,"message":"[INFO] Application started successfully","level":"info","kubernetes":{"namespace_name":"logspam","pod_name":"log-generator-...","container_name":"log-generator"}}
</code></pre></div></div>

<p>That’s the forwarding pipeline working end-to-end.</p>

<h4 id="optional-switch-to-the-nodeport-for-external-access">Optional: Switch to the NodePort for External Access</h4>

<p>If you want to simulate forwarding to a truly external syslog destination — or verify the NodePort is reachable from outside the cluster — the helper script patches the CLF URL to use the worker node IP and NodePort instead:</p>

<p>📄 <a href="/posts/forwarding-openshift-logs-syslog/5-get-external-url.sh">5-get-external-url.sh</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash 5-get-external-url.sh
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>External syslog URL: tcp://10.0.1.42:31514
Patching ClusterLogForwarder...
clusterlogforwarder.observability.openshift.io/instance patched
</code></pre></div></div>

<p>The script reads the IP of a worker node, constructs the full <code class="language-plaintext highlighter-rouge">tcp://&lt;node-ip&gt;:31514</code> URL, and patches the <code class="language-plaintext highlighter-rouge">syslog-out</code> output in place. In a real deployment this is where you’d substitute your centralized syslog server’s hostname and port — Splunk, Graylog, a managed SIEM — and nothing else in this setup changes.</p>

<hr />

<p>That’s the forwarding pipeline complete. Logs leave your cluster, arrive at a syslog server you control, and the only thing connecting them is a <code class="language-plaintext highlighter-rouge">ClusterLogForwarder</code> and a service endpoint. In a real deployment, swap the internal service URL for your centralized syslog server — Splunk, Graylog, a managed SIEM — and nothing else changes.</p>

<p>If you want to go further and query those same logs interactively inside the cluster, the next post covers adding LokiStack and Grafana to the same setup: <a href="/2026/04/13/visualizing-openshift-logs-grafana.html">Querying OpenShift Logs with LokiStack and Grafana</a>.</p>

<hr />

<h2 id="references">References</h2>

<ul>
  <li><a href="https://docs.redhat.com/en/documentation/red_hat_openshift_logging/6.5/html/configuring_logging/configuring-log-forwarding">OCP Logging 6.5 Docs: Configuring log forwarding</a></li>
  <li><a href="https://docs.redhat.com/en/documentation/red_hat_openshift_logging/6.5/html-single/installing_logging/index">OCP Logging 6.5 Docs: Installing logging</a></li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[OpenShift ships with a full logging stack — collect, store, and view logs all within the cluster using the Logging Operator and LokiStack. That’s a great starting point. But at some point you need logs to outlive the cluster, land in a centralized system that aggregates across environments, or feed into compliance and audit tooling that expects syslog. This is a quick why and how-to for configuring the OpenShift Logging Operator to forward logs to an external syslog server.]]></summary></entry><entry><title type="html">Extending OpenShift Monitoring: Exporting Metrics and Building Custom Dashboards</title><link href="/2026/04/01/extending-openshift-monitoring.html" rel="alternate" type="text/html" title="Extending OpenShift Monitoring: Exporting Metrics and Building Custom Dashboards" /><published>2026-04-01T00:00:00+00:00</published><updated>2026-04-01T00:00:00+00:00</updated><id>/2026/04/01/extending-openshift-monitoring</id><content type="html" xml:base="/2026/04/01/extending-openshift-monitoring.html"><![CDATA[<p>OpenShift ships with a production-grade monitoring stack — Prometheus, Thanos, and a built-in Observe console — ready to go from day one. This is a quick why and how-to for two ways to extend it: pulling metrics into a CSV and deploying a custom Grafana instance you can actually edit.</p>

<h2 id="why-this-matters">Why This Matters</h2>

<p>The built-in dashboards are managed by the Cluster Monitoring Operator (CMO), which keeps them stable and consistent across upgrades. That’s the right behavior for platform infrastructure — but it means they’re not yours to modify, and the data inside them isn’t easy to export.</p>

<p>That gap matters more than people realize. Capacity planning, chargeback reporting, compliance exports, custom application dashboards — these are everyday asks that fall outside what the platform monitoring stack is designed to handle. Most teams hit this and assume it’s a dead end.</p>

<p>It isn’t. OpenShift exposes the full <strong>Thanos Querier API</strong> to any authorized client. You can query it directly with a Python script and get a CSV, or deploy your own Grafana instance alongside the platform one and build whatever dashboards your team needs — all without touching a single platform component.</p>

<hr />

<h2 id="the-steps">The Steps</h2>

<p><strong>Approach 1 — Query Thanos directly and export a CSV</strong></p>

<ol>
  <li>Confirm you’re logged into the cluster with <code class="language-plaintext highlighter-rouge">oc</code></li>
  <li>Run <code class="language-plaintext highlighter-rouge">query_thanos.py</code> with your PromQL query, time range, and output file</li>
  <li>Open the CSV</li>
</ol>

<p><strong>Approach 2 — Deploy a custom Grafana instance</strong></p>

<ol>
  <li>Create a namespace and service account</li>
  <li>Create a long-lived token secret for that service account</li>
  <li>Install the Grafana Operator from OperatorHub</li>
  <li>Deploy a Grafana instance</li>
  <li>Create a datasource pointing at the Thanos Querier</li>
  <li>Retrieve your Grafana credentials and log in</li>
  <li>Build dashboards and export data from the UI</li>
</ol>

<hr />

<h2 id="how-to-do-it">How To Do It</h2>

<h3 id="approach-1-query-thanos-directly--csv">Approach 1: Query Thanos Directly → CSV</h3>

<p>A Python script authenticates against the Thanos Querier route using your existing <code class="language-plaintext highlighter-rouge">oc</code> session, runs a PromQL range query, and writes the results to a CSV file. No UI, no intermediate steps.</p>

<p><strong>Prerequisites:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">oc</code> CLI installed and logged in (<code class="language-plaintext highlighter-rouge">oc whoami</code> should return your username)</li>
  <li>Python 3.10+</li>
  <li><code class="language-plaintext highlighter-rouge">pip install requests</code></li>
</ul>

<p><strong>Script:</strong> <a href="/posts/extending-openshift-monitoring/parsePrometheusData/query_thanos.py">query_thanos.py</a></p>

<p><strong>Run it:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python query_thanos.py <span class="se">\</span>
  <span class="nt">--query</span> <span class="s1">'sum by (namespace) (container_memory_working_set_bytes{container!=""})'</span> <span class="se">\</span>
  <span class="nt">--days</span> 7 <span class="se">\</span>
  <span class="nt">--output</span> memory_by_namespace.csv
</code></pre></div></div>

<p><strong>What it does under the hood:</strong></p>

<p>At its core, the script is making one authenticated HTTP request to the Thanos query API. You can see the same thing manually with two <code class="language-plaintext highlighter-rouge">oc</code> commands and a <code class="language-plaintext highlighter-rouge">curl</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Get the external Thanos route URL</span>
<span class="nb">export </span><span class="nv">THANOS_URL</span><span class="o">=</span><span class="si">$(</span>oc get route thanos-querier <span class="nt">-n</span> openshift-monitoring <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{.spec.host}'</span><span class="si">)</span>

<span class="c"># Get your bearer token from the active oc session</span>
<span class="nb">export </span><span class="nv">TOKEN</span><span class="o">=</span><span class="si">$(</span>oc <span class="nb">whoami</span> <span class="nt">-t</span><span class="si">)</span>

<span class="c"># Set a time range (Unix epoch)</span>
<span class="nb">export </span><span class="nv">END_TIME</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> +%s<span class="si">)</span>
<span class="nb">export </span><span class="nv">START_TIME</span><span class="o">=</span><span class="si">$(</span><span class="nb">date</span> <span class="nt">-d</span> <span class="s2">"7 days ago"</span> +%s<span class="si">)</span>

<span class="c"># Hit the Thanos range query API directly</span>
curl <span class="nt">-k</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> <span class="se">\</span>
  <span class="s2">"https://</span><span class="nv">$THANOS_URL</span><span class="s2">/api/v1/query_range"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s1">'query=sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate) by (pod)'</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"start=</span><span class="nv">$START_TIME</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"end=</span><span class="nv">$END_TIME</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s1">'step=3600s'</span> <span class="o">&gt;</span> my_metrics.json
</code></pre></div></div>

<p>The Python script does exactly this — gets the route, grabs the token, calls the same endpoint — then goes one step further: it parses the JSON response and pivots it into a readable CSV instead of leaving it as raw JSON.</p>

<p><strong>Example output:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DateTime,default,kube-system,openshift-monitoring
2025-03-24T00:00:00.000Z,1073741824,536870912,2147483648
2025-03-24T00:15:00.000Z,1073741824,536870912,2147483648
</code></pre></div></div>

<p><strong>Options:</strong></p>

<table>
  <thead>
    <tr>
      <th>Flag</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--query</code> / <code class="language-plaintext highlighter-rouge">-q</code></td>
      <td>PromQL query (required)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--days</code> / <code class="language-plaintext highlighter-rouge">-d</code></td>
      <td>How many days of history to fetch (required)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--step</code> / <code class="language-plaintext highlighter-rouge">-s</code></td>
      <td>Interval between data points, e.g. <code class="language-plaintext highlighter-rouge">15m</code>, <code class="language-plaintext highlighter-rouge">1h</code> (optional, auto-calculated)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--label</code> / <code class="language-plaintext highlighter-rouge">-l</code></td>
      <td>Label to use as column headers (optional, auto-detected)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--output</code> / <code class="language-plaintext highlighter-rouge">-o</code></td>
      <td>Output CSV file path (default: <code class="language-plaintext highlighter-rouge">output.csv</code>)</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Access note:</strong> Your <code class="language-plaintext highlighter-rouge">oc</code> user needs the <code class="language-plaintext highlighter-rouge">cluster-monitoring-view</code> cluster role to query Thanos across all namespaces. If you can already see metrics in the OpenShift web console, you likely already have it.</p>
</blockquote>

<hr />

<h3 id="approach-2-deploy-your-own-grafana-instance">Approach 2: Deploy Your Own Grafana Instance</h3>

<p>A full Grafana UI — editable dashboards, panel-level CSV export, ad-hoc PromQL — running in its own namespace and pointed at the same Thanos backend as the platform stack.</p>

<hr />

<h4 id="step-1-create-a-namespace-and-service-account">Step 1: Create a Namespace and Service Account</h4>

<p>📄 <a href="/posts/extending-openshift-monitoring/customGrafana/1-configureNamespaceSA.sh">1-configureNamespaceSA.sh</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc create namespace my-custom-metrics
oc project my-custom-metrics

oc create sa custom-grafana-sa

oc adm policy add-cluster-role-to-user cluster-monitoring-view <span class="nt">-z</span> custom-grafana-sa
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">cluster-monitoring-view</code> role allows the service account to read metrics across all namespaces via Thanos.</p>

<hr />

<h4 id="step-2-create-a-long-lived-token-secret">Step 2: Create a Long-Lived Token Secret</h4>

<p>📄 <a href="/posts/extending-openshift-monitoring/customGrafana/2-SA-secret.yaml">2-SA-secret.yaml</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 2-SA-secret.yaml
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Secret</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">custom-grafana-token</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">kubernetes.io/service-account.name</span><span class="pi">:</span> <span class="s">custom-grafana-sa</span>
<span class="na">type</span><span class="pi">:</span> <span class="s">kubernetes.io/service-account-token</span>
</code></pre></div></div>

<p>OCP 4.11+ deprecated <code class="language-plaintext highlighter-rouge">oc sa get-token</code>. A secret of type <code class="language-plaintext highlighter-rouge">kubernetes.io/service-account-token</code> annotated with the service account name is the supported replacement — OpenShift automatically populates the <code class="language-plaintext highlighter-rouge">token</code> key.</p>

<hr />

<h4 id="step-3-install-the-grafana-operator">Step 3: Install the Grafana Operator</h4>

<p>📄 <a href="/posts/extending-openshift-monitoring/customGrafana/3-install-Grafana.yaml">3-install-Grafana.yaml</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 3-install-Grafana.yaml
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">OperatorGroup</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-operator-group</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">my-custom-metrics</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">targetNamespaces</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">my-custom-metrics</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Subscription</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-operator</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">my-custom-metrics</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">channel</span><span class="pi">:</span> <span class="s">v5</span>
  <span class="na">installPlanApproval</span><span class="pi">:</span> <span class="s">Automatic</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-operator</span>
  <span class="na">source</span><span class="pi">:</span> <span class="s">community-operators</span>
  <span class="na">sourceNamespace</span><span class="pi">:</span> <span class="s">openshift-marketplace</span>
</code></pre></div></div>

<p>This installs the community Grafana Operator from OperatorHub — the path Red Hat’s own documentation points to for custom Grafana on OCP 4. Wait for the operator pod before continuing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc get pods <span class="nt">-n</span> my-custom-metrics <span class="nt">-w</span>
</code></pre></div></div>

<hr />

<h4 id="step-4-deploy-a-grafana-instance">Step 4: Deploy a Grafana Instance</h4>

<p>📄 <a href="/posts/extending-openshift-monitoring/customGrafana/4-create-Grafana-instance.yaml">4-create-Grafana-instance.yaml</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 4-create-Grafana-instance.yaml
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">grafana.integreatly.org/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Grafana</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">custom-grafana</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">my-custom-metrics</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">grafana</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">route</span><span class="pi">:</span>
    <span class="na">spec</span><span class="pi">:</span> <span class="pi">{}</span>
  <span class="na">config</span><span class="pi">:</span>
    <span class="na">log</span><span class="pi">:</span>
      <span class="na">mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">console"</span>
    <span class="na">auth</span><span class="pi">:</span>
      <span class="na">disable_login_form</span><span class="pi">:</span> <span class="s2">"</span><span class="s">false"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">route: spec: {}</code> tells the operator to create an OpenShift Route automatically. The <code class="language-plaintext highlighter-rouge">app: grafana</code> label is how the datasource in the next step knows which instance to attach to.</p>

<hr />

<h4 id="step-5-connect-grafana-to-the-thanos-querier">Step 5: Connect Grafana to the Thanos Querier</h4>

<p>📄 <a href="/posts/extending-openshift-monitoring/customGrafana/5-create-Grafana-datasource.yaml">5-create-Grafana-datasource.yaml</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>oc apply <span class="nt">-f</span> 5-create-Grafana-datasource.yaml
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">grafana.integreatly.org/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">GrafanaDatasource</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">openshift-monitoring-datasource</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">my-custom-metrics</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">instanceSelector</span><span class="pi">:</span>
    <span class="na">matchLabels</span><span class="pi">:</span>
      <span class="na">app</span><span class="pi">:</span> <span class="s">grafana</span>
  <span class="na">valuesFrom</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">targetPath</span><span class="pi">:</span> <span class="s2">"</span><span class="s">secureJsonData.httpHeaderValue1"</span>
      <span class="na">valueFrom</span><span class="pi">:</span>
        <span class="na">secretKeyRef</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">custom-grafana-token"</span>
          <span class="na">key</span><span class="pi">:</span> <span class="s2">"</span><span class="s">token"</span>
  <span class="na">datasource</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">OpenShift Thanos</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">prometheus</span>
    <span class="na">access</span><span class="pi">:</span> <span class="s">proxy</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s">https://thanos-querier.openshift-monitoring.svc.cluster.local:9091</span>
    <span class="na">isDefault</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">jsonData</span><span class="pi">:</span>
      <span class="na">tlsSkipVerify</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">httpHeaderName1</span><span class="pi">:</span> <span class="s">Authorization</span>
    <span class="na">secureJsonData</span><span class="pi">:</span>
      <span class="na">httpHeaderValue1</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Bearer</span><span class="nv"> </span><span class="s">${token}"</span>
</code></pre></div></div>

<p>A few things worth understanding:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">thanos-querier.openshift-monitoring.svc.cluster.local:9091</code> is the in-cluster Thanos service. Port 9091 gives access across all namespaces.</li>
  <li><code class="language-plaintext highlighter-rouge">valuesFrom</code> pulls the bearer token from the secret in Step 2 and injects it into the <code class="language-plaintext highlighter-rouge">Authorization</code> header automatically — no manual token management.</li>
  <li><code class="language-plaintext highlighter-rouge">tlsSkipVerify: true</code> is standard for in-cluster service-to-service communication.</li>
</ul>

<hr />

<h4 id="step-6-get-your-credentials-and-log-in">Step 6: Get Your Credentials and Log In</h4>

<p>📄 <a href="/posts/extending-openshift-monitoring/customGrafana/6-get-grafana-creds.sh">6-get-grafana-creds.sh</a></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash 6-get-grafana-creds.sh
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>========================================
  Grafana Login Details
========================================
URL:      https://custom-grafana-route-my-custom-metrics.apps.your-cluster.example.com
Username: admin
Password: &lt;generated&gt;
========================================
</code></pre></div></div>

<p>The Grafana Operator stores generated credentials in a secret. This script retrieves and decodes them.</p>

<p>From here you have a fully editable Grafana instance. Use <strong>Explore</strong> for ad-hoc PromQL, or <strong>Dashboards</strong> to build and save views. To export any panel as CSV: panel menu → <strong>Inspect</strong> → <strong>Data</strong> → <strong>Download CSV</strong>.</p>

<hr />

<h2 id="which-approach-should-you-use">Which Approach Should You Use?</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Thanos → CSV (Python)</th>
      <th>Custom Grafana</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Best for</strong></td>
      <td>One-off data pulls, automation, CI pipelines</td>
      <td>Ongoing dashboards, visual exploration</td>
    </tr>
    <tr>
      <td><strong>Setup</strong></td>
      <td>Minimal — just <code class="language-plaintext highlighter-rouge">oc</code> and Python</td>
      <td>~10 minutes of YAML</td>
    </tr>
    <tr>
      <td><strong>Output</strong></td>
      <td>CSV file</td>
      <td>Interactive UI + CSV export</td>
    </tr>
    <tr>
      <td><strong>Maintenance</strong></td>
      <td>None</td>
      <td>Operator updates, token rotation</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="references">References</h2>

<ul>
  <li><a href="https://access.redhat.com/solutions/7018296">Red Hat KB: How to export Prometheus metrics into a CSV format in RHOCP4</a></li>
  <li><a href="https://www.redhat.com/en/blog/custom-grafana-dashboards-red-hat-openshift-container-platform-4">Red Hat Blog: Custom Grafana dashboards for Red Hat OpenShift Container Platform 4</a></li>
  <li><a href="https://cloud.redhat.com/experts/o11y/ocp-grafana/">Red Hat Cloud Experts: Deploying Grafana on OpenShift 4</a></li>
</ul>]]></content><author><name></name></author><summary type="html"><![CDATA[OpenShift ships with a production-grade monitoring stack — Prometheus, Thanos, and a built-in Observe console — ready to go from day one. This is a quick why and how-to for two ways to extend it: pulling metrics into a CSV and deploying a custom Grafana instance you can actually edit.]]></summary></entry></feed>