<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nerav Doshi</title>
    <description>The latest articles on DEV Community by Nerav Doshi (@agenticdevops).</description>
    <link>https://dev.to/agenticdevops</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3916785%2F423b2322-f2d4-4fee-8576-b0537c2866f0.png</url>
      <title>DEV Community: Nerav Doshi</title>
      <link>https://dev.to/agenticdevops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/agenticdevops"/>
    <language>en</language>
    <item>
      <title>Managed Services vs Self-Hosted: When to Hand Over the Keys</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Fri, 21 Aug 2026 20:16:36 +0000</pubDate>
      <link>https://dev.to/agenticdevops/managed-services-vs-self-hosted-when-to-hand-over-the-keys-3cpl</link>
      <guid>https://dev.to/agenticdevops/managed-services-vs-self-hosted-when-to-hand-over-the-keys-3cpl</guid>
      <description>&lt;h1&gt;
  
  
  Managed Services vs Self-Hosted: When to Hand Over the Keys
&lt;/h1&gt;

&lt;p&gt;☁ Cloud Without the Chaos — Article 02&lt;/p&gt;

&lt;p&gt;You've placed the workload in the cloud. That decision is done. Now comes the one nobody gives you a clean framework for: do you run the infrastructure yourself, or do you hand the keys to a managed service?&lt;/p&gt;

&lt;p&gt;I worked with a customer who answered this the hard way — twice. Once on their own platform, and once after they'd already made the switch. Both incidents involved the same underlying failure mode. Neither one turned out the way anyone in the room expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warehouse analogy
&lt;/h2&gt;

&lt;p&gt;In supply chain, this decision has a name: &lt;a href="https://dev.to/posts/hybrid-cloud-architecture-on-prem-vs-cloud-tradeoffs/"&gt;make vs. buy&lt;/a&gt;. Do you run your own warehouse, or use a third-party logistics provider (3PL)?&lt;/p&gt;

&lt;p&gt;Running your own warehouse means you control everything — layout, staffing, hours, how fast you respond when a shipment needs to move at 2am. It also means you own every forklift breakdown and every understaffed shift.&lt;/p&gt;

&lt;p&gt;A 3PL means someone else owns the building, the labor, the equipment. You focus on what you sell instead of how it's stored and moved. But you're also dependent on their systems, their SLAs, and their priorities when your shipment competes with someone else's for dock space.&lt;/p&gt;

&lt;p&gt;Cloud infrastructure is the same trade in different clothes. Self-hosted is your own warehouse. Managed platforms are the 3PL. What follows is what that trade actually looked like for one team, in production, with real numbers attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The renewal that forced the question
&lt;/h2&gt;

&lt;p&gt;The customer had a lean platform team self-managing Kubernetes — patching, logging, monitoring, upgrades, all of it in-house. Their platform renewal was coming up, expensive, and stacked against a team that was already stretched. The maintenance burden that made the renewal conversation painful had a specific shape: every major Kubernetes upgrade meant validating deprecated and removed APIs across their entire application estate, inside a fixed window, before the old control plane version went out of support.&lt;/p&gt;

&lt;p&gt;That window was 30 days. The estate was roughly 1,400 microservices across 120+ namespaces. And on one upgrade cycle, something got missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident that made the case for managed
&lt;/h2&gt;

&lt;p&gt;During a post-upgrade hotfix rollout, a Helm chart still referencing a removed Ingress API version hit the CD pipeline. The deployment failed. So the team did what you're trained to do — they rolled back.&lt;/p&gt;

&lt;p&gt;The rollback failed too, and it failed for a specific, mechanical reason worth understanding rather than waving past. Helm doesn't recompute a prior release from source when you roll back — it retrieves the immutable historical manifest stored in a cluster Secret and re-applies it, which means that manifest still has to pass live API discovery validation on the way back in. Kubernetes 1.22 had removed the &lt;code&gt;v1beta1&lt;/code&gt; Ingress API group entirely. The API server had no way to accept a manifest written against an API version that no longer existed on the cluster. Helm halted before applying anything.&lt;/p&gt;

&lt;p&gt;With the rollback path closed, the Ingress controller hit a state-sync failure. Payments traffic started returning 502s. Engineers resolved it by hand — removing orphaned resources, rewriting the manifests to the current API schema, and re-running the deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forty-five minutes of active, customer-facing outage on payments&lt;/strong&gt;, before service was restored.&lt;/p&gt;

&lt;p&gt;The team built a deprecation-scanning guardrail afterward. It was the right fix for the immediate problem. But it didn't touch the actual bottleneck, which wasn't technical — it was a small team with too many competing priorities trying to own an entire platform lifecycle by hand. That's what pushed the renewal conversation toward evaluating ROSA HCP and EKS as replacements, rather than just renewing what they had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ROSA HCP over EKS
&lt;/h2&gt;

&lt;p&gt;The deciding factor wasn't a feature checklist. It was a question of who owns which lifecycle. EKS would have meant the customer still assembling and operating most of the surrounding components themselves. ROSA HCP's integrated stack meant Red Hat owned Day 2 operations on the platform — the control plane, the surrounding components, the patching cadence — while the customer kept ownership of the application layer. For a team whose whole problem was "we can't keep operating all of this ourselves," that division of labor was the actual point — the same &lt;a href="https://dev.to/posts/stopped-managing-kubernetes-start-managing-applications/"&gt;operational-ownership calculus&lt;/a&gt; that plays out anywhere a team decides what to stop managing directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surprise: managed doesn't mean hands-off
&lt;/h2&gt;

&lt;p&gt;Here's where the assumption cracked. The team expected that ROSA's managed Day 2 operations meant node draining, cordoning, and worker upgrades would happen the way a serverless abstraction behaves — invisibly, without touching running workloads.&lt;/p&gt;

&lt;p&gt;That's not how it works. On ROSA HCP, control plane upgrades are decoupled and handled automatically by Red Hat. Worker node pool upgrades are not automatic in the same way — they require customer initiation, and once initiated, they're still governed by whatever workload configuration is sitting on top of those nodes.&lt;/p&gt;

&lt;p&gt;A critical CVE forced the question. Red Hat's SREs patched the managed control plane within 24 hours of disclosure — the response time this engagement actually observed, not a contractual SLA figure. When the customer then initiated the worker pool upgrade, ROSA's automated drain process ran into a misconfigured PodDisruptionBudget (PDB) on a legacy service. The PDB was blocking pod eviction, so the node drain stalled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contrast that matters
&lt;/h2&gt;

&lt;p&gt;This is the moment worth sitting with, because it's the clearest evidence for how this trade actually plays out.&lt;/p&gt;

&lt;p&gt;On the self-hosted side, the team discovered their outage the way most self-hosted teams discover outages: customers saw 502s first. Forty-five minutes of active, visible damage before anyone had it under control.&lt;/p&gt;

&lt;p&gt;On ROSA HCP, Red Hat's SRE automation caught the stalled drain and alerted the customer's team directly — before any workload was affected. The Machine Config Operator's drain process retried against the exponential backoff pattern it's designed to use rather than failing outright, so once the customer's application team fixed their own PDB configuration, the rollout resumed and completed on its own. No support ticket. No manual cluster-operator intervention from Red Hat.&lt;/p&gt;

&lt;p&gt;That episode took &lt;strong&gt;2 to 2.5 hours&lt;/strong&gt; from alert to resolution — longer than the payments outage. But customer-facing impact was zero, because the misconfigured PDB had done its one useful job: it prevented running pods from being prematurely terminated while the drain was stuck.&lt;/p&gt;

&lt;p&gt;Managed didn't mean faster. It meant the failure mode moved from "customer-facing outage you discover yourself" to "silent stall you get alerted to before anyone outside notices."&lt;/p&gt;

&lt;h2&gt;
  
  
  The exposure window nobody puts in the vendor deck
&lt;/h2&gt;

&lt;p&gt;The CVE episode had a second layer worth naming plainly, because it's the part every "just move to managed" pitch tends to skip.&lt;/p&gt;

&lt;p&gt;Red Hat's SREs patched the control plane in 24 hours. But because worker upgrades required customer initiation, and the customer held off starting that upgrade until their standard maintenance window — partly out of exactly the fear that then materialized as the PDB stall — the worker nodes running actual application workloads stayed unpatched for roughly &lt;strong&gt;four days&lt;/strong&gt;. The 2.5-hour PDB delay was a small piece of that gap. The bigger piece was hesitation.&lt;/p&gt;

&lt;p&gt;Managed infrastructure narrowed the attack surface. It didn't eliminate the scheduling risk sitting on the customer's side of the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually closes the gap
&lt;/h2&gt;

&lt;p&gt;The fix wasn't anything Red Hat needed to change. It was proactive PodDisruptionBudget hygiene at the manifest level, applied consistently, so that node pool upgrades stop being a feared, manually-delayed event and become a routine background operation the team can trust. Once that trust exists, there's no reason to hold a CVE-driven worker upgrade for a scheduled maintenance window. The team's target shifted from a hesitant four-day window down to an automated 24-hour response following any control-plane security release — closing the exposure gap by removing the fear that created it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks at scale
&lt;/h2&gt;

&lt;p&gt;At 120+ namespaces and 1,400+ microservices, PDB hygiene enforced only in CI/CD isn't enough. Pipeline linting has a blind spot: it can't see PodDisruptionBudgets generated at runtime by Operators, bundled inside third-party Helm charts, or applied directly via &lt;code&gt;kubectl&lt;/code&gt; outside any pipeline at all. A policy that only lives in CI will pass clean while the cluster quietly accumulates exactly the kind of misconfiguration that stalled the worker upgrade in the first place. &lt;a href="https://dev.to/posts/terraform-managed-openshift-state/"&gt;State drift on managed platforms follows the same pattern&lt;/a&gt; — the enforcement point that looked sufficient in isolation turns out to have blind spots once the estate gets large enough.&lt;/p&gt;

&lt;p&gt;The customer's answer was to move enforcement to the cluster's front door — deploying Kyverno as a &lt;code&gt;ValidatingWebhookConfiguration&lt;/code&gt; at the Kubernetes API server itself, so no PDB, regardless of where it came from, gets accepted onto the cluster without passing policy. It's deployed today. It hasn't yet been tested against a live attempted violation in production, so I'd call it the right architecture rather than a proven one — worth watching, not yet worth treating as settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway that doesn't show up in vendor documentation
&lt;/h2&gt;

&lt;p&gt;The lesson here isn't "avoid managed services," and it isn't "always choose managed services" either. It's this: handing off infrastructure management doesn't reduce your governance burden. It relocates it, and intensifies it, at the API layer.&lt;/p&gt;

&lt;p&gt;Once a vendor owns everything below the API — control plane, patching, node lifecycle — your job stops being "keep the cluster running." It becomes enforcing strict, API-level tenant policy so that the vendor's automation doesn't hit an invisible wall it has no way to see past. The vendor owns the warehouse. You still own what's on the shelves, and if what's on the shelves is stacked wrong, the automated forklift stops moving no matter how good the warehouse is.&lt;/p&gt;

&lt;h2&gt;
  
  
  A framework, not a rule
&lt;/h2&gt;

&lt;p&gt;Three questions do most of the real work in this decision:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Is the scarce resource time, or is it control?&lt;/strong&gt;&lt;br&gt;
If the team is small and stretched, and the system isn't your competitive differentiator, lean managed. If it's core to what makes your product actually work, the operational knowledge from running it yourself has value beyond uptime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Can you operate it today, under pressure — not eventually, today?&lt;/strong&gt;&lt;br&gt;
If not, that's not an argument for self-hosting. It's a sign you need training or headcount before this decision is even yours to make cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. When it breaks, whose policy is actually holding it together?&lt;/strong&gt;&lt;br&gt;
Managed services don't remove your governance obligations. They just move the point of failure from "the platform" to "whatever policy your team enforces on top of it." Know which one you're actually testing before you sign the renewal.&lt;/p&gt;

&lt;p&gt;None of these have a universal answer. They have an answer for your team, this quarter, with the staffing and policy discipline you actually have — not the version you're hoping to build eventually.&lt;/p&gt;




&lt;h2&gt;
  
  
  GitHub Repo
&lt;/h2&gt;

&lt;p&gt;No companion repository for this article — this is a decision-framework piece about managed vs. self-hosted infrastructure, not an implementation walkthrough. Article 3 in this series returns to concrete implementation, where API-level governance becomes its own architecture decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloud Without the Chaos, Article 03&lt;/strong&gt; picks up where this one leaves off: once you've handed the keys to a managed platform and enforced policy at the API layer, connectivity becomes the next architecture decision — VPNs, Direct Connect, ExpressRoute, and what actually breaks when two environments have to talk to each other reliably.&lt;/p&gt;

</description>
      <category>rosahcp</category>
      <category>openshift</category>
      <category>kubernetes</category>
      <category>managedservices</category>
    </item>
    <item>
      <title>Built My First Local Embeddings Pipeline (Files + a Live URL)</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:48:51 +0000</pubDate>
      <link>https://dev.to/agenticdevops/built-my-first-local-embeddings-pipeline-files-a-live-url-1mik</link>
      <guid>https://dev.to/agenticdevops/built-my-first-local-embeddings-pipeline-files-a-live-url-1mik</guid>
      <description>&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; An embedding model doesn't generate text — it converts text into a list of numbers (a vector) that represents the &lt;em&gt;meaning&lt;/em&gt; of that text. Two pieces of writing about similar topics end up with similar vectors, even if they don't share any exact words, which is what makes semantic search possible: you can find "things that mean something like this," not just "things that contain this exact word." One detail that surprised me going in: the vector is always the same fixed length, no matter how long the original text is — a one-sentence note and a 10-page article both come out as the same-sized list of numbers, like a fingerprint that summarizes something much bigger into a fixed format. RAG (retrieval-augmented generation) is built on this: embed a bunch of documents, embed a question the same way, and find whichever documents are numerically closest to the question's meaning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ran:&lt;/strong&gt; Built this up one command at a time instead of writing the full script upfront — easier to see what each piece actually does before combining them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Pull the embedding model&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull nomic-embed-text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — Test the embedding API directly, before writing any Python&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ollama exposes embeddings over a local HTTP API, so you can sanity-check it works with a plain &lt;code&gt;curl&lt;/code&gt; before involving any code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:11434/api/embeddings &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "nomic-embed-text", "prompt": "hello world"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns a JSON object with a single &lt;code&gt;embedding&lt;/code&gt; field — a list of 768 numbers. That's the whole idea of an embedding in one command: text in, fixed-length list of numbers out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — Install the Python pieces&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip3 &lt;span class="nb"&gt;install &lt;/span&gt;ollama chromadb requests beautifulsoup4 &lt;span class="nt"&gt;--break-system-packages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4 — Embed one local file, interactively&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rather than run a full script blind, this is small enough to do a few lines at a time in a Python shell (&lt;code&gt;python3&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;02-oc-cli-mentor-system-prompt.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nomic-embed-text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# → 768
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hit &lt;code&gt;ModuleNotFoundError: No module named 'ollama'&lt;/code&gt; on the first attempt here — Step 3 hadn't been run yet. A reminder that even a five-step walkthrough has room to skip a step by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — Store it in Chroma&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chromadb&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chromadb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PersistentClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./chroma_db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;collection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_or_create_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;today_i_ran_notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;02-oc-cli-mentor-system-prompt.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# → 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 6 — Add a longer file, and hit a real limit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Repeating Step 4 against a longer draft article failed differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ollama._types.ResponseError: the input length exceeds the context length
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nomic-embed-text&lt;/code&gt; has a 2048-token context window, and the longer draft (13,985 characters) blew past it. Quick fix for now — truncate to a safe length before embedding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not the right long-term fix (more on that below), but enough to keep moving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 7 — Add a live URL as a source&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pulling in a web page instead of a local file needs one extra step first: fetching the page and stripping out the HTML noise (nav bars, scripts, footers) so only the article text gets embedded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://pipelineandprompts.com/posts/03-1b-vs-3b-memory-comparison/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;script&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;style&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;footer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;header&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aside&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;article_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;separator&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# → 3359
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gets fed into the same &lt;code&gt;ollama.embeddings()&lt;/code&gt; call from Step 4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Three sources ended up embedded across the two files plus the URL from the steps above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Characters sent&lt;/th&gt;
&lt;th&gt;Vector dimensions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cloud-without-chaos-01.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;local file&lt;/td&gt;
&lt;td&gt;6,000 (truncated from 13,985)&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;02-oc-cli-mentor-system-prompt.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;local file&lt;/td&gt;
&lt;td&gt;2,941&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry 03 (live URL)&lt;/td&gt;
&lt;td&gt;web article&lt;/td&gt;
&lt;td&gt;3,359&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stood out. First, every vector came back at exactly 768 dimensions regardless of source length — confirms the "fixed-size fingerprint" idea from the context section wasn't just theory. Second, and more important: the truncation fix meant &lt;strong&gt;57% of the longest draft's content (7,985 of 13,985 characters) never made it into its embedding at all.&lt;/strong&gt; That's not a rounding error — over half the article is invisible to any future search against that vector. This is exactly why real RAG pipelines chunk long documents into smaller overlapping pieces instead of truncating: chunking keeps everything searchable, truncation just quietly throws away whatever didn't fit.&lt;/p&gt;

&lt;p&gt;The URL fetch also worked cleanly — 3,359 characters extracted from the live page is close to the article's actual body length, suggesting the nav/footer/script stripping in the script did its job without pulling in much boilerplate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; The pipeline works end-to-end — files and live URLs, embedded into a queryable local store — but truncation is a real data-loss bug, not just a technical footnote, once you're working with anything longer than a short note. Next entry should replace it with actual chunking. Also still untested: whether querying this store actually retrieves the right document for a real question — that's the actual point of building this, and it's still ahead of us.&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>rag</category>
      <category>embeddings</category>
      <category>chromadb</category>
    </item>
    <item>
      <title>Compared Quantization Levels: Q4 vs Q8 vs FP16 on llama3.2:1b</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Wed, 19 Aug 2026 21:44:52 +0000</pubDate>
      <link>https://dev.to/agenticdevops/compared-quantization-levels-q4-vs-q8-vs-fp16-on-llama321b-2bf2</link>
      <guid>https://dev.to/agenticdevops/compared-quantization-levels-q4-vs-q8-vs-fp16-on-llama321b-2bf2</guid>
      <description>&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; A model's weights — the numbers it uses to reason — are normally stored at high precision, like FP16 (16-bit floating point). Quantization compresses those numbers down to fewer bits (Q8, Q4, etc.) to shrink the file size and memory footprint, trading some numerical precision for big space savings. It's a similar idea to compressing a high-resolution photo into a smaller JPEG: the file shrinks a lot, some detail is technically lost, but for most practical purposes it's hard to tell the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ran:&lt;/strong&gt; Before pulling anything new, ran &lt;code&gt;ollama show llama3.2:1b&lt;/code&gt; to check what Entry 01's baseline model actually was — and it turned out to already be &lt;strong&gt;Q8_0&lt;/strong&gt;, not an unspecified "default" as I'd assumed back in Entry 01. Worth a correction: Entry 01 and Entry 03's &lt;code&gt;1b&lt;/code&gt; numbers were Q8, not full precision. Pulled two more variants for comparison: &lt;code&gt;llama3.2:1b-instruct-q4_K_M&lt;/code&gt; (an explicit 4-bit quantization) and &lt;code&gt;llama3.2:1b-instruct-fp16&lt;/code&gt; (full, uncompressed precision). Loaded each in turn, sent the same test question, and captured &lt;code&gt;ollama ps&lt;/code&gt; / &lt;code&gt;ps aux&lt;/code&gt; for all three quantization levels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Q4_K_M&lt;/th&gt;
&lt;th&gt;Q8_0 (Entry 01 baseline)&lt;/th&gt;
&lt;th&gt;FP16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Download size&lt;/td&gt;
&lt;td&gt;807 MB&lt;/td&gt;
&lt;td&gt;1.3 GB&lt;/td&gt;
&lt;td&gt;2.5 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ollama ps&lt;/code&gt; loaded size&lt;/td&gt;
&lt;td&gt;997 MB&lt;/td&gt;
&lt;td&gt;1.5 GB&lt;/td&gt;
&lt;td&gt;2.7 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process RSS&lt;/td&gt;
&lt;td&gt;~0.94 GB&lt;/td&gt;
&lt;td&gt;~1.24 GB&lt;/td&gt;
&lt;td&gt;~2.57 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory scales cleanly with bit-width across all three: roughly 0.94 GB → 1.24 GB → 2.57 GB as precision doubles from 4-bit to 8-bit to 16-bit. That's a tidier relationship than I expected going in — quantization level is a solid, predictable lever for memory sizing.&lt;/p&gt;

&lt;p&gt;For quality, I asked all three quantization levels the same real question: "what are good free AI tools that can create simple Google Slides using instructions?" None gave a clean answer. All three produced long lists mixing legitimate tools (Canva) with tools that have nothing to do with making slides — Q4 suggested Midjourney and DALL-E, FP16 added Artbreeder, Deep Dream Generator, and Prisma to the same mix. Full precision didn't fix this: FP16's list was just as padded with irrelevant suggestions as Q4's, and Q4 wasn't noticeably worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Memory scaled cleanly and predictably with quantization level — roughly doubling at each step from Q4 to Q8 to FP16, matching the bit-width math almost exactly. Answer quality told a different story: going all the way to full precision didn't reduce hallucinated suggestions on this open-ended recommendation question, and Q4 wasn't visibly worse than FP16. That's not proof quantization is "safe" in general — this was one soft question, not a rigorous eval — but it's a reminder that the real quality gap probably shows up on harder tasks (math, code, precise instruction-following), not casual recommendation queries. Also worth remembering going forward: check &lt;code&gt;ollama show &amp;lt;model&amp;gt;&lt;/code&gt; before assuming what quantization a "default" pull actually gives you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ollama show llama3.2:1b
&lt;span class="go"&gt;  quantization        Q8_0

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ollama show llama3.2:1b-instruct-q4_K_M
&lt;span class="go"&gt;  quantization        Q4_K_M

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ollama ps
&lt;span class="go"&gt;NAME                         ID              SIZE      PROCESSOR    CONTEXT    UNTIL
llama3.2:1b-instruct-fp16    2887c3d03e74    2.7 GB    100% GPU     4096       4 minutes from now

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;ollama
&lt;span class="go"&gt;flyers  10763  0.3  15.7  438101456  2630128  ??  S  llama-server --model ... -c 4096
flyers   2256  0.0   0.3  436826160    56448  ??  S  ollama serve

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ollama ps
&lt;span class="go"&gt;NAME                           ID              SIZE      PROCESSOR    CONTEXT    UNTIL
llama3.2:1b-instruct-q4_K_M    22bc6b92eb01    997 MB    100% GPU     4096       4 minutes from now

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;ollama
&lt;span class="go"&gt;flyers  10786  0.2  5.7  436484688  963808  ??  S  llama-server --model ... -c 4096
flyers   2256  0.0  0.4  436826160   60400  ??  S  ollama serve
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ollama</category>
      <category>llm</category>
      <category>todayiran</category>
      <category>quantization</category>
    </item>
    <item>
      <title>Agentic AI Infrastructure: What It Takes to Do It Safely</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Wed, 19 Aug 2026 03:00:06 +0000</pubDate>
      <link>https://dev.to/agenticdevops/agentic-ai-infrastructure-what-it-takes-to-do-it-safely-2bio</link>
      <guid>https://dev.to/agenticdevops/agentic-ai-infrastructure-what-it-takes-to-do-it-safely-2bio</guid>
      <description>&lt;p&gt;&lt;em&gt;Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚡ Byte Size Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;See why we shipped an OpenShift diagnostic MCP server as &lt;strong&gt;read-only by design&lt;/strong&gt;, and the RBAC wall that made write access harder than it looks&lt;/li&gt;
&lt;li&gt;Walk through a real failed remediation test where an agent recommended a correct-looking fix built on stale, deprecated config — and what that failure mode actually is&lt;/li&gt;
&lt;li&gt;Get the maturity-gated approval architecture we designed for write access — and why it's still sitting on paper, not in production&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Story
&lt;/h2&gt;

&lt;p&gt;We built &lt;code&gt;mcp-sre-tools&lt;/code&gt; — an MCP server that exposes OpenShift and Kubernetes diagnostics to an LLM, wired into Claude Desktop and n8n, covering ARO, ROSA HCP, OSD-GCP, and generic clusters. Nine diagnostic tools: &lt;code&gt;get_cluster_health&lt;/code&gt;, &lt;code&gt;diagnose_crashloop&lt;/code&gt;, &lt;code&gt;get_failing_pods&lt;/code&gt;, and others in that family. &lt;code&gt;READ_ONLY_MODE&lt;/code&gt; is on by default, and there are no write tools in the codebase at all. That part shipped clean.&lt;/p&gt;

&lt;p&gt;The friction started when we scoped what came next: a remediation mode, where the agent wouldn't just diagnose a broken deployment — it would patch it.&lt;/p&gt;

&lt;p&gt;That's where the story stopped being a build story and became an organizational one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Platform engineers and developers landed on opposite sides of the same question almost immediately, and for reasons that turned out to be more substantial than the usual risk-aversion reflex.&lt;/p&gt;

&lt;p&gt;Developers were comfortable trusting agent-proposed changes roughly the way they'd trust a colleague's pull request — read the diff, sanity-check it, merge it. Platform engineers pushed back hard, and their objection wasn't reflexive. It was specific: a PR from a colleague comes with inspectable reasoning. You can ask them why. An LLM's proposed patch doesn't carry that same trail — the "why" is buried in a forward pass, not a code review comment.&lt;/p&gt;

&lt;p&gt;Business stakeholders, meanwhile, were worried about something simpler and more immediate: an autonomous agent breaking a critical application in production.&lt;/p&gt;

&lt;p&gt;Three legitimate concerns, three different vocabularies for the same underlying question — how much do we trust a system whose reasoning we can't fully inspect, applied to infrastructure we can't afford to break?&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Existing Approaches Fall Short
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for RBAC and call it solved. Scope the agent's service account to a namespace, give it patch permissions on Deployments and nothing else, and let it operate inside a fence.&lt;/p&gt;

&lt;p&gt;That fence has a hole in it. Meaningful remediation almost always eventually needs to touch Secrets or environment variables — a misconfigured database connection string, an expired credential reference, a missing env var causing a crash loop. The moment your remediation scope includes Secrets, "namespace-scoped RBAC" stops being clean sandboxing and starts being a much bigger trust surface than the phrase implies.&lt;/p&gt;

&lt;p&gt;We didn't have a way around that with RBAC alone. So we fell back to a narrower, honest justification for read-only: even without write access, a diagnostic agent cuts human mean-time-to-resolution. It's a smaller value proposition than full self-healing, but it's a real one — and it's the one we could actually defend without hand-waving.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Diagram 1 — as built:&lt;/strong&gt; the shipped, read-only MCP architecture.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpipelineandprompts.com%2Fimages%2Fdiagrams%2Fagentic-ai-infrastructure-safety-asbuilt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpipelineandprompts.com%2Fimages%2Fdiagrams%2Fagentic-ai-infrastructure-safety-asbuilt.png" alt="As-built read-only architecture"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The controls we designed the server to work with — note the repo intentionally ships without a default &lt;code&gt;rbac.yaml&lt;/code&gt;, to stay adaptable across cluster types and org policies. Deployment teams are expected to write their own scoped &lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/rbac/" rel="noopener noreferrer"&gt;&lt;code&gt;ClusterRole&lt;/code&gt;/&lt;code&gt;RoleBinding&lt;/code&gt;&lt;/a&gt; tailored to their access model; the sample below shows the shape we recommend, not a default that ships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namespace-scoped RBAC (recommended, not shipped)&lt;/strong&gt; — bind the MCP server's service account to &lt;code&gt;Role&lt;/code&gt;/&lt;code&gt;RoleBinding&lt;/code&gt; resources scoped per-namespace, not a cluster-wide &lt;code&gt;ClusterRole&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service-account-based access&lt;/strong&gt; — no static kubeconfig or personal credentials in the agent's execution path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NetworkPolicy egress/ingress restriction&lt;/strong&gt; — the MCP server's pod network is fenced to only the cluster API and the LLM endpoint it needs to reach&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging and observability as non-functional requirements&lt;/strong&gt; — every tool call is logged, not bolted on after the fact
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Recommended shape, not a shipped default — deployment teams write their own&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-sre-tools-reader&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;target-namespace&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deployments"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replicasets"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Diagram 2 — proposed, never implemented:&lt;/strong&gt; the maturity-gated write-access flow.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;This is a design artifact, not a shipped system.&lt;/strong&gt; The kill switch and rollback automation shown below were never built.&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─────────────────────┐
  │   Anomaly detected   │
  └──────────┬───────────┘
             │
             ▼
  ┌─────────────────────────────┐
  │  Agent proposes RBAC-scoped  │
  │      remediation action      │
  └──────────┬───────────────────┘
             │
             ▼
  ┌─────────────────────────────┐
  │      Risk-based routing      │
  │  low risk        high risk   │
  └──────┬───────────────┬───────┘
         │               │
         ▼               ▼
  ┌─────────────┐ ┌─────────────────────┐
  │ Light review │ │  Human approval gate │
  └──────┬───────┘ └──────────┬───────────┘
         │                    │
         └─────────┬──────────┘
                    ▼
        ┌───────────────────────┐
        │   Kill-switch check    │  ◄── [NEVER BUILT]
        └───────────┬─────────────┘
                    │  pass
                    ▼
        ┌───────────────────────┐
        │  Execute + full audit  │
        │        record          │
        └───────────┬─────────────┘
                    │
                    ▼
        ┌───────────────────────┐
        │ Pre-captured rollback  │  ◄── [NEVER BUILT]
        │   plan (ready before   │
        │      execution)        │
        └───────────┬─────────────┘
                    │
                    ▼
        ┌───────────────────────┐
        │    Outcome logged      │
        └───────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flow we designed, on paper, for a future write-capable mode:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Anomaly detected&lt;/li&gt;
&lt;li&gt;Agent proposes an RBAC-scoped remediation action&lt;/li&gt;
&lt;li&gt;Risk-based routing — low-risk actions get lighter review, high-risk actions route to human approval&lt;/li&gt;
&lt;li&gt;Kill-switch check before execution&lt;/li&gt;
&lt;li&gt;Execution, with a full audit record&lt;/li&gt;
&lt;li&gt;Pre-captured rollback plan available before the action runs, not written after&lt;/li&gt;
&lt;li&gt;Outcome logged&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two components in that list were never built: the kill switch and the rollback automation. They exist as a design, not as shipped capability. Worth saying plainly, because it's the honest state of the project, not a gap we're hiding.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works: Step by Step
&lt;/h2&gt;

&lt;p&gt;For the part that &lt;em&gt;is&lt;/em&gt; live — the read-only diagnostic path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An alert or a manual query triggers the agent via Claude Desktop or an n8n workflow&lt;/li&gt;
&lt;li&gt;The agent calls one or more of the nine MCP tools (e.g., &lt;code&gt;diagnose_crashloop&lt;/code&gt;) against the target cluster&lt;/li&gt;
&lt;li&gt;The MCP server's service account, scoped by namespace RBAC, executes the read-only API calls&lt;/li&gt;
&lt;li&gt;Results return to the LLM, which synthesizes a diagnosis&lt;/li&gt;
&lt;li&gt;A human reads the diagnosis and decides what to do next — the agent stops there&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the part that stayed on paper — the proposed remediation path — see the seven-step flow above. It never advanced past step 2 in production; steps 3 through 7 are design artifacts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security and Operational Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RBAC and least privilege.&lt;/strong&gt; The repo ships without a default &lt;code&gt;rbac.yaml&lt;/code&gt; by design, to stay adaptable across cluster types — deployment teams write their own scoped &lt;code&gt;ClusterRole&lt;/code&gt;/&lt;code&gt;RoleBinding&lt;/code&gt; for their environment. That's also where the write-mode proposal broke down: remediation that needs to touch Secrets can't stay inside a tidy read-only-style RBAC boundary no matter who authors it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets exposure.&lt;/strong&gt; The live tool set never reads or writes Secret contents. The unbuilt remediation mode is exactly where that boundary would have been tested, and wasn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius.&lt;/strong&gt; Contained by design in the shipped version — nine read-only tools can't mutate cluster state, full stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback strategy.&lt;/strong&gt; For the live tools: not applicable, nothing is mutated. For the proposed write mode: rollback was designed as a &lt;em&gt;pre-captured&lt;/em&gt; plan, generated before execution rather than reconstructed after a failure — but this was never implemented, and the one real recovery event we had (below) had to be handled manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditability.&lt;/strong&gt; Every diagnostic tool call is logged. The proposed write-mode flow adds a full audit record as one of its seven stages — again, unbuilt.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;We ran a private test — outside the public repo — with write/patch/upgrade-capable variants of the agent, specifically to see whether the self-healing story held up. It didn't, and the way it failed is the most important finding in this whole project.&lt;/p&gt;

&lt;p&gt;The agent correctly diagnosed a real problem. Then it recommended a fix built on fluentd-era OpenShift Logging configuration — the collector layer — against a cluster already running the current Vector-and-Loki stack. The recommendation looked plausible. It targeted a collector and CRD shape that the cluster had already moved past.&lt;/p&gt;

&lt;p&gt;At close to the same time, in the same test, the agent made a second, unrelated bad call: a recommendation to change the cluster's node-level hardware settings via the worker &lt;code&gt;MachineSet&lt;/code&gt;. That recommendation, applied, provisioned the wrong instance type.&lt;/p&gt;

&lt;p&gt;Two independent, unrelated bad recommendations landing at the same time — one on the logging stack, one on node hardware — made it look, in the moment, like a single cascading failure. It wasn't. Untangling that during recovery took longer than fixing either problem alone would have, precisely because the two failures got conflated. Recovery meant two separate fixes, not one clean GitOps revert: an updated &lt;code&gt;ClusterLogging&lt;/code&gt; CR to throttle ingress at the collector layer and redirect output pipelines directly to the Loki backend, and a reconciliation of the worker &lt;code&gt;MachineSet&lt;/code&gt; back to its approved &lt;code&gt;providerSpec&lt;/code&gt; baseline — scaling down the misconfigured nodes and letting the Machine Operator provision replacements.&lt;/p&gt;

&lt;p&gt;The label that matters here isn't "hallucination" in the dramatic sense people usually mean. The logging recommendation wasn't nonsense — it was &lt;strong&gt;training-data staleness masquerading as competence&lt;/strong&gt;: correct-sounding reasoning built on a collector and config shape the cluster had already moved past. That's a more insidious failure mode than the black-box framing usually implies, because the output looks exactly as confident whether the underlying knowledge is current or stale.&lt;/p&gt;

&lt;p&gt;The instance-type recommendation was a different kind of mistake — not stale knowledge, just a bad call. What made the incident harder to diagnose wasn't either failure alone, but the two landing together and looking, briefly, like one problem instead of two.&lt;/p&gt;

&lt;p&gt;Scaling this up doesn't just mean more permissions — though it does mean that, and the platform team's original objection was concrete and correct on that point alone. It also means the bottleneck moves. Once you gate every write action behind human approval, the approval step itself becomes the constraint, and approval fatigue doesn't scale linearly with the number of things an agent proposes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only shipped, write-mode didn't&lt;/strong&gt; — and the RBAC wall around Secrets access is a real reason, not a cautious excuse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two unrelated bad calls can look like one big failure&lt;/strong&gt; — a stale-knowledge fix and a separate bad hardware recommendation landed together and were harder to untangle than either would've been alone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Governance architecture is easy to design and hard to ship&lt;/strong&gt; — the kill switch and rollback automation are still just a diagram&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  GitHub Repo
&lt;/h2&gt;

&lt;p&gt;Full implementation: &lt;a href="https://github.com/agentic-devops/mcp-sre-tools" rel="noopener noreferrer"&gt;mcp-sre-tools&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;The open question this leaves for the series: what does a &lt;em&gt;safe&lt;/em&gt; path to write access actually look like, given that the blocker wasn't RBAC mechanics but the reliability of the reasoning behind each proposed action? That's worth its own piece.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openshift</category>
      <category>mcp</category>
      <category>aiagents</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Compared llama3.2:1b vs llama3.2:3b Memory Footprint</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Fri, 14 Aug 2026 17:57:01 +0000</pubDate>
      <link>https://dev.to/agenticdevops/compared-llama321b-vs-llama323b-memory-footprint-34ki</link>
      <guid>https://dev.to/agenticdevops/compared-llama321b-vs-llama323b-memory-footprint-34ki</guid>
      <description>&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; The number in a model name like &lt;code&gt;1b&lt;/code&gt; or &lt;code&gt;3b&lt;/code&gt; refers to parameters — roughly, the tunable values inside the model that encode what it's learned. More parameters generally means better reasoning and more nuanced answers, at the cost of more memory and slower responses. One thing that trips people up early with Ollama: typing &lt;code&gt;ollama run &amp;lt;model&amp;gt;&lt;/code&gt; drops you into an interactive chat session (marked by the &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; prompt), which is a different context from your regular shell. Commands like &lt;code&gt;ollama ps&lt;/code&gt; only work back in a normal terminal prompt — typed inside the chat session, they get sent to the model as a question instead of running as a command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ran:&lt;/strong&gt; Pulled &lt;code&gt;llama3.2:3b&lt;/code&gt; (the 3-billion-parameter sibling of Entry 01's &lt;code&gt;1b&lt;/code&gt; model), loaded it into memory, and captured the same &lt;code&gt;ollama ps&lt;/code&gt; / &lt;code&gt;ps aux | grep ollama&lt;/code&gt; numbers for a direct comparison. Along the way, typed &lt;code&gt;ollama ps&lt;/code&gt; inside the chat session by mistake — got a confused response from the model instead of the process table, a good real-world example of the shell-vs-chat distinction above. Then asked the same test question from Entry 02 ("how do I check the status of pods in my namespace?") — this time against the plain &lt;code&gt;llama3.2:3b&lt;/code&gt; model, not the constrained &lt;code&gt;oc-mentor&lt;/code&gt; build from Entry 02.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;1b&lt;/code&gt; (Entry 01)&lt;/th&gt;
&lt;th&gt;&lt;code&gt;3b&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ollama ps&lt;/code&gt; size&lt;/td&gt;
&lt;td&gt;1.5 GB&lt;/td&gt;
&lt;td&gt;2.5 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process RSS&lt;/td&gt;
&lt;td&gt;~1.24 GB&lt;/td&gt;
&lt;td&gt;~2.47 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parameters&lt;/td&gt;
&lt;td&gt;1B&lt;/td&gt;
&lt;td&gt;3B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tripling the parameter count roughly doubled the memory footprint — not a 1:1 scaling, which is worth remembering when estimating resource requests for larger models.&lt;/p&gt;

&lt;p&gt;On the question test: since this run used the plain &lt;code&gt;3b&lt;/code&gt; model rather than the &lt;code&gt;oc-mentor&lt;/code&gt; Modelfile from Entry 02, the answer came back as a verbose, multi-option explanation using &lt;code&gt;kubectl&lt;/code&gt; — not &lt;code&gt;oc&lt;/code&gt;, and not the single-command format Entry 02 enforced. That's not a knock on the bigger model; it's a reminder that the constrained, single-command behavior from Entry 02 came from the system prompt, not from model size. A bigger base model without that constraint just reverts to its default training bias (which, unsurprisingly, leans &lt;code&gt;kubectl&lt;/code&gt; over &lt;code&gt;oc&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; More parameters bought roughly 2x memory for 3x the parameter count — a useful data point for future sizing — but it didn't buy domain-specific behavior on its own. Getting oc-only, single-command answers still requires the system prompt from Entry 02, regardless of model size.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ollama ps
&lt;span class="go"&gt;NAME           ID              SIZE      PROCESSOR    CONTEXT    UNTIL
llama3.2:3b    a80c4f17acd5    2.5 GB    100% GPU     4096       4 minutes from now

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;ollama
&lt;span class="go"&gt;flyers  94626  0.2  15.1  438021552  2531232  ??  S  llama-server --model ... -c 4096
flyers   1905  0.0   0.4  436904528    60864  ??  S  ollama serve
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Correction (Aug 19, 2026): the &lt;code&gt;1b&lt;/code&gt; baseline referenced from Entry 01 is Q8_0 quantization, not an unspecified default — see &lt;a href="https://pipelineandprompts.com/posts/04-quantization-q4-q8-fp16/" rel="noopener noreferrer"&gt;Entry 04&lt;/a&gt; for the full breakdown across quantization levels.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>todayiran</category>
      <category>modelcomparison</category>
    </item>
    <item>
      <title>The Hybrid Cloud Isn't a Destination. It's a Set of Tradeoffs.</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Wed, 12 Aug 2026 19:09:17 +0000</pubDate>
      <link>https://dev.to/agenticdevops/the-hybrid-cloud-isnt-a-destination-its-a-set-of-tradeoffs-dkj</link>
      <guid>https://dev.to/agenticdevops/the-hybrid-cloud-isnt-a-destination-its-a-set-of-tradeoffs-dkj</guid>
      <description>&lt;p&gt;&lt;em&gt;☁ Cloud Without the Chaos — Article 01 · ⏱ 10 min read&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;☁ Byte Size Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hybrid cloud isn't a strategy — it's the outcome of real architectural decisions&lt;/li&gt;
&lt;li&gt;Most teams default to cloud or on-prem for the wrong reasons (inertia, not intent)&lt;/li&gt;
&lt;li&gt;Five dimensions drive every defensible placement decision: latency, data sovereignty, cost profile, operational ownership, and blast radius&lt;/li&gt;
&lt;li&gt;A reusable decision framework for evaluating any workload — run it before the migration starts, not after&lt;/li&gt;
&lt;li&gt;Network design thinking from logistics applies directly to hybrid cloud architecture — and most platform engineers have never seen it framed this way&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;p&gt;I spent a few years working in supply chain and logistics before I ended up in cloud infrastructure. One of the first things you learn in that world is that the network is never fully yours.&lt;/p&gt;

&lt;p&gt;You've got your own warehouses. You've got third-party fulfilment centres you use when demand spikes. You've got last-mile carriers you don't control at all. The goods flow across all of it depending on cost, speed, capacity, and — critically — what you're willing to risk having disrupted.&lt;/p&gt;

&lt;p&gt;Nobody in logistics calls this a "hybrid warehouse strategy." They just call it running a network. &lt;strong&gt;The goal isn't ideological purity about who owns the infrastructure. The goal is getting the right things where they need to be, reliably, at a cost that makes sense.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I moved into cloud and infrastructure, this framing was almost entirely absent. Teams were either "cloud first" or "on-prem forever" — both as fixed identities, not deliberate decisions. When hybrid cloud entered the picture, it was less of a strategy and more of a situation that accumulated over time.&lt;/p&gt;

&lt;p&gt;This series is about bringing some rigour back to those decisions. Not the marketing version of hybrid cloud — the practical one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "Hybrid Cloud" Actually Means
&lt;/h2&gt;

&lt;p&gt;Strip the vendor positioning out first.&lt;/p&gt;

&lt;p&gt;Hybrid cloud means running workloads across at least two environments — typically some combination of on-premises infrastructure and one or more public cloud providers — with some degree of orchestration or connectivity between them. No requirement for a unified control plane, a specific vendor stack, or a particular ratio of workloads on each side.&lt;/p&gt;

&lt;p&gt;In practice, most enterprise organisations are already hybrid whether they planned it or not. Legacy systems that can't be migrated sit in the data centre. New workloads spin up in AWS or Azure. An OpenShift cluster runs on-prem for regulated workloads. A Kubernetes service runs in Google Kubernetes Engine (GKE) for the developer-facing platform. The network connects them, imperfectly, and the operations team holds it all together with documentation, duct tape, and institutional memory.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The question isn't whether you're hybrid. It's whether you're hybrid on purpose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article focuses on the strategic decision layer — where workloads should live and why. The technical implementation of connectivity (VPNs, Direct Connect, ExpressRoute) comes later in the series.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Most Hybrid Architectures Are Accidents
&lt;/h2&gt;

&lt;p&gt;Most hybrid architectures evolve the same way.&lt;/p&gt;

&lt;p&gt;A team starts running everything on-prem because that's what they know and what the business already owns. At some point there's a project that needs to move fast, or a new team that doesn't want to wait for physical provisioning, and something lands in a public cloud. Then another thing. Then three more things during a peak traffic event that nobody had capacity for on-prem. Now you're hybrid.&lt;/p&gt;

&lt;p&gt;Or the reverse: a cloud migration programme moves a significant portion of the estate to public cloud. But some workloads — the database with the compliance requirements, the legacy system that can't be containerised, the one application that cannot tolerate a 100ms latency spike — stay behind. Now you're hybrid.&lt;/p&gt;

&lt;p&gt;Neither is necessarily wrong. But both share a problem: &lt;strong&gt;the placement decisions were made reactively, and nobody wrote down the reasoning.&lt;/strong&gt; Two years later, a new engineer reverse-engineers why a workload is where it is, and the answer is "history."&lt;/p&gt;

&lt;p&gt;That's a real cost. It makes it harder to have intelligent conversations about what should move, what should stay, and what the actual cost model is.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Five Dimensions That Actually Matter
&lt;/h2&gt;

&lt;p&gt;After working across on-prem and cloud environments — and spending years in supply chain thinking about network design — five dimensions consistently drive the right placement decision. Answer these clearly and you'll reach a defensible architectural position.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Latency tolerance
&lt;/h3&gt;

&lt;p&gt;How sensitive is this workload to network round-trip time? A real-time manufacturing control system talking to floor sensors has hard latency requirements that cloud can't reliably satisfy. A reporting pipeline that runs overnight doesn't. This is easy to underestimate — especially for workloads that look like batch jobs but have real-time dependencies buried inside them.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Data sovereignty and compliance
&lt;/h3&gt;

&lt;p&gt;Where does the data have to live? Regulated data in healthcare, financial services, or government frequently has hard requirements about physical location, access controls, and audit trails. Public cloud providers have made significant progress with regional isolation and compliance certifications — but not every jurisdiction accepts "it's in an EU data centre" as sufficient. Know the actual requirements before you architect.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cost profile and demand shape
&lt;/h3&gt;

&lt;p&gt;Cloud is cheaper at the margins — burst capacity, unpredictable demand, short-lived environments. On-prem is cheaper at steady state — predictable, high-utilisation workloads where you'd be running equivalent cloud compute continuously. Applying cloud economics to steady-state workloads, or on-prem economics to spiky ones, is the most common cost mistake in hybrid architecture. The shape of demand matters as much as the raw compute cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Operational ownership appetite
&lt;/h3&gt;

&lt;p&gt;Cloud shifts operational responsibility to the provider for the infrastructure layer, but it doesn't eliminate operational work — it changes it. You're still responsible for your application, your data, your network configuration, your identity model. A managed Kubernetes service in the cloud may be cheaper in operational time than a self-managed OpenShift cluster, even if the compute costs are higher. Or it may not be, depending on your team. Be honest about this.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Blast radius and failure domain
&lt;/h3&gt;

&lt;p&gt;If this workload fails, or the environment it runs in fails, what's the impact? Placing a critical system on a single public cloud introduces a blast radius tied to that provider's regional availability. Placing it on-prem introduces a different blast radius — your data centre's power, cooling, and network uplinks. Neither is inherently safer. The question is which failure mode you have more control over, more visibility into, and more organisational capacity to recover from.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Decision Framework You Can Actually Use
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LATENCY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sub-5ms requirements, or direct integration with on-prem systems?&lt;/td&gt;
&lt;td&gt;→ On-prem or edge. Everything else is negotiable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SOVEREIGNTY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hard data residency requirement your cloud provider can't satisfy with certifications?&lt;/td&gt;
&lt;td&gt;→ On-prem or private cloud. If they can satisfy it, document that explicitly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DEMAND SHAPE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Consistent, predictable utilisation above ~60%?&lt;/td&gt;
&lt;td&gt;→ On-prem likely wins on cost. Spiky, seasonal, or unpredictable? → Cloud wins.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OPERATIONS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Does your team have the skills and bandwidth to manage the infrastructure layer?&lt;/td&gt;
&lt;td&gt;→ On-prem or self-managed. Would a managed service free up meaningful capacity? → Cloud.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BLAST RADIUS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which failure mode is more recoverable — cloud regional outage, or on-prem infrastructure failure?&lt;/td&gt;
&lt;td&gt;→ Architect toward the one you can recover from faster.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run any workload through these five questions. If the answers point consistently in one direction, the decision is clear. If they conflict — and they often do — you're having an explicit architectural conversation rather than an implicit one. That's already progress.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Placement Decision Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload type&lt;/th&gt;
&lt;th&gt;Typical best fit&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real-time control systems&lt;/td&gt;
&lt;td&gt;On-prem / edge&lt;/td&gt;
&lt;td&gt;Latency and reliability requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regulated data stores (PII, PCI, PHI)&lt;/td&gt;
&lt;td&gt;On-prem or certified cloud&lt;/td&gt;
&lt;td&gt;Sovereignty — verify per jurisdiction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev/test environments&lt;/td&gt;
&lt;td&gt;Cloud&lt;/td&gt;
&lt;td&gt;Short-lived, variable, low blast radius&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Burst compute (ML training, batch jobs)&lt;/td&gt;
&lt;td&gt;Cloud&lt;/td&gt;
&lt;td&gt;Elastic capacity on demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal developer platforms&lt;/td&gt;
&lt;td&gt;On-prem or hybrid&lt;/td&gt;
&lt;td&gt;Integration with internal systems, security posture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer-facing APIs&lt;/td&gt;
&lt;td&gt;Cloud&lt;/td&gt;
&lt;td&gt;Global distribution, managed scaling, CDN integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy monoliths (can't containerise)&lt;/td&gt;
&lt;td&gt;On-prem&lt;/td&gt;
&lt;td&gt;Migration cost exceeds benefit — for now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disaster recovery / backup&lt;/td&gt;
&lt;td&gt;Cloud&lt;/td&gt;
&lt;td&gt;Offsite resilience, pay-per-use when idle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What the Supply Chain World Understood First
&lt;/h2&gt;

&lt;p&gt;In logistics, there's a discipline called &lt;strong&gt;network design&lt;/strong&gt; — deciding where to place inventory, distribution centres, and transport capacity to optimise for cost, speed, and resilience across a network. It's not about owning everything. It's not about outsourcing everything. It's about understanding the specific characteristics of each node and flow, and making placement decisions accordingly.&lt;/p&gt;

&lt;p&gt;The most important lesson from that world: &lt;strong&gt;over-centralising creates fragility, and over-distributing creates complexity that erodes the resilience you were trying to build.&lt;/strong&gt; The answer is never all-in on one model. It's a considered network, with explicit reasoning behind each decision.&lt;/p&gt;

&lt;p&gt;Hybrid cloud is the same problem. The data centre is your warehouse. The cloud is your third-party fulfilment network. Some workloads belong in your warehouse because you need control, compliance, or latency. Others belong in the fulfilment network because you need elasticity and don't want to own the capacity. The failure mode is treating this as a binary choice rather than a network design problem.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 From the field:&lt;/strong&gt; Before any migration or placement decision, create a simple workload inventory: what it does, what it talks to, what its latency and compliance requirements are, and what it currently costs to run. Most teams don't have this and make significant architectural decisions without it. A rough spreadsheet surfaces conversations that would otherwise never happen.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Honest Tradeoffs
&lt;/h2&gt;

&lt;p&gt;There's no version of hybrid cloud that's free of complexity. Running across two environments means two sets of networking concerns, two operational models, two security boundaries, and often two teams with different toolsets. That's real cost, and it's worth naming before you commit.&lt;/p&gt;

&lt;p&gt;The question isn't whether hybrid cloud creates complexity — it does. The question is whether the complexity is worth it relative to the alternative. For most enterprise organisations with a heterogeneous workload mix and real compliance requirements, the answer is yes. But "yes" should come from an analysis, not from a vendor briefing or a conference keynote.&lt;/p&gt;

&lt;p&gt;The teams that get this right treat placement as a first-class architectural concern — documented, revisited periodically, and tied to actual cost and operational data. Not a one-time migration project. Not a fixed philosophical stance. A living decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid cloud is not a destination&lt;/strong&gt; — it's a network of deliberate placement decisions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most hybrid architectures are accidents&lt;/strong&gt; — the goal is to make yours intentional&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Five dimensions drive placement&lt;/strong&gt; — latency, sovereignty, cost profile, operational ownership, blast radius&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat workload placement like network design&lt;/strong&gt; — optimise for the whole, not the individual node&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity is unavoidable&lt;/strong&gt; — the question is whether it's justified by the requirements&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next in Cloud Without the Chaos
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Article 02 — Managed Services vs Self-Hosted: When to Hand Over the Keys&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You've placed the workload in the cloud. Now: do you manage the infrastructure yourself, or hand it to a managed service? The next article works through the real tradeoffs — including the moment a managed service genuinely saves your team 20 hours a week, and the moment it quietly doubles your incident response time.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Read the &lt;a href="https://pipelineandprompts.com/series/cloud-without-the-chaos/" rel="noopener noreferrer"&gt;Cloud Without the Chaos series index&lt;/a&gt; for the full learning path.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hybridcloud</category>
      <category>cloudarchitecture</category>
      <category>platformengineering</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Built a constrained 'oc CLI Mentor' with a system prompt</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Mon, 10 Aug 2026 15:38:05 +0000</pubDate>
      <link>https://dev.to/agenticdevops/today-i-ran-built-a-constrained-oc-cli-mentor-with-a-system-prompt-2h3i</link>
      <guid>https://dev.to/agenticdevops/today-i-ran-built-a-constrained-oc-cli-mentor-with-a-system-prompt-2h3i</guid>
      <description>&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; A system prompt is a set of instructions given to a model before any real conversation starts — think of it as a character brief handed to an actor before they walk on stage. It shapes how the model behaves for the rest of the session without the user having to repeat those instructions every time. In Ollama, the way you package a base model plus a system prompt into a new, reusable model is called a Modelfile — it works a lot like a Dockerfile: a small text file that says "start from this base, then apply these settings," which Ollama then builds into a named model you can run like any other. &lt;code&gt;oc&lt;/code&gt; is OpenShift's command-line tool (the OpenShift equivalent of &lt;code&gt;kubectl&lt;/code&gt;) — the goal here was to force a model to act as a mentor that &lt;em&gt;only&lt;/em&gt; responds with real &lt;code&gt;oc&lt;/code&gt; commands, never plain-language explanations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ran:&lt;/strong&gt; Wrote a Modelfile on top of &lt;code&gt;llama3.2:1b&lt;/code&gt; with a system prompt instructing the model to answer OpenShift questions only with the exact &lt;code&gt;oc&lt;/code&gt; command needed, no prose explanation, and to say plainly when a question can't be answered that way. (Side note: pasting a multi-line heredoc into the terminal broke on quote handling — switched to &lt;code&gt;vi&lt;/code&gt; to write the file directly, which handled the paste cleanly.) Built it with &lt;code&gt;ollama create oc-mentor -f Modelfile&lt;/code&gt;, then tested it with one on-topic question and one deliberately off-topic question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; The off-topic test worked exactly as intended — asked for a pasta recipe, and the model declined and redirected instead of just answering anyway, which means the constraint actually held rather than being ignored under a mildly adversarial prompt.&lt;/p&gt;

&lt;p&gt;The on-topic test is where it got more interesting. Asked "how do I check the status of pods in my namespace?", and the model followed the &lt;em&gt;format&lt;/em&gt; instruction correctly (gave a command, not an explanation) but the command itself was subtly wrong: &lt;code&gt;oc get pods --all-namespaces --output jsonpath='{.items[*].metadata.namespace}'&lt;/code&gt; scopes to &lt;em&gt;all&lt;/em&gt; namespaces (not "my namespace" as asked) and the jsonpath only extracts the namespace field — it wouldn't actually show pod status at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; A tight system prompt is good at enforcing behavioral boundaries — refusing off-topic requests, sticking to a response format — but it doesn't guarantee the technical content inside that format is correct. The model followed instructions perfectly while giving a command that wouldn't do what was asked. Worth remembering before trusting any command output from a small local model without checking it first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; how &lt;span class="k"&gt;do &lt;/span&gt;I check the status of pods &lt;span class="k"&gt;in &lt;/span&gt;my namespace?
&lt;span class="sb"&gt;`&lt;/span&gt;oc get pods &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt;
&lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[*].metadata.namespace}'&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt;

&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; what&lt;span class="s1"&gt;'s a good recipe for pasta?
I can'&lt;/span&gt;t provide a cooking recipe. Is there anything &lt;span class="k"&gt;else &lt;/span&gt;I can &lt;span class="nb"&gt;help &lt;/span&gt;you
with?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ollama</category>
      <category>llm</category>
      <category>systemprompt</category>
      <category>todayiran</category>
    </item>
    <item>
      <title>Deployed llama3.2:1b locally via Ollama</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Fri, 07 Aug 2026 20:59:10 +0000</pubDate>
      <link>https://dev.to/agenticdevops/deployed-llama321b-locally-via-ollama-3pa3</link>
      <guid>https://dev.to/agenticdevops/deployed-llama321b-locally-via-ollama-3pa3</guid>
      <description>&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; Ollama is a tool for running large language models directly on your own machine, instead of calling a hosted API. &lt;code&gt;llama3.2:1b&lt;/code&gt; refers to Meta's Llama 3.2 model at 1 billion parameters — "parameters" being roughly the number of tunable values inside the model that determine what it's learned. 1 billion is small by LLM standards (production-grade models often run into the hundreds of billions), which is exactly why it's a good fit for testing on a laptop or Mac mini: fast to download, fast to load, and light enough to actually watch its resource usage in real time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ran:&lt;/strong&gt; Installed Ollama, pulled &lt;code&gt;llama3.2:1b&lt;/code&gt;, and loaded it with a prompt to force it into memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; &lt;code&gt;ollama ps&lt;/code&gt; reported 1.5 GB for the loaded model. Breaking that down with &lt;code&gt;ps aux&lt;/code&gt;, the actual &lt;code&gt;llama-server&lt;/code&gt; process held 1.24 GB RSS, with the background &lt;code&gt;ollama serve&lt;/code&gt; daemon adding a separate ~22 MB. The &lt;code&gt;ollama ps&lt;/code&gt; figure (1.5 GB) runs a bit higher than raw process RSS — likely accounting for GPU-resident memory that doesn't show up the same way in &lt;code&gt;ps&lt;/code&gt;. Worth noting: this was captured after the model was already loaded, not before, so it's a snapshot rather than a true before/after delta.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; ~1.25 GB of process RSS plus GPU memory overhead is the real number to size a &lt;code&gt;resources.requests.memory&lt;/code&gt; floor around for this model — not the ~1.3 GB download size, which undercounts the actual runtime footprint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ollama ps
&lt;span class="go"&gt;NAME           ID              SIZE      PROCESSOR    CONTEXT    UNTIL
llama3.2:1b    baf6a787fdff    1.5 GB    100% GPU     4096       About a minute from now

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;ollama
&lt;span class="go"&gt;flyers  95443  0.1  7.7  436962064  1295248  ??  S  llama-server --model ... -c 4096
flyers   1456  0.0  0.1  436899728    22336  ??  S  ollama serve
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Correction (Aug 19, 2026): the &lt;code&gt;llama3.2:1b&lt;/code&gt; baseline used in this entry is Q8_0 quantization, not an unspecified default — see &lt;a href="https://pipelineandprompts.com/posts/04-quantization-q4-q8-fp16/" rel="noopener noreferrer"&gt;Entry 04&lt;/a&gt; for the full breakdown across quantization levels.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>todayiran</category>
      <category>localai</category>
    </item>
    <item>
      <title>Database Migrations During Zero-Downtime Deploys on OpenShift</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Fri, 07 Aug 2026 20:57:49 +0000</pubDate>
      <link>https://dev.to/agenticdevops/database-migrations-during-zero-downtime-deploys-on-openshift-3kne</link>
      <guid>https://dev.to/agenticdevops/database-migrations-during-zero-downtime-deploys-on-openshift-3kne</guid>
      <description>&lt;p&gt;🛠️ &lt;strong&gt;Pipelines in the Wild #6&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚡ Byte Size Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Implement the four-phase Expand/Contract pattern — nullable column, batched backfill, code pivot, then constraint — so schema changes never ship coupled to application code&lt;/li&gt;
&lt;li&gt;Understand why Argo CD's "rollback" can revert your container image but cannot touch a database schema, and why that gap is the actual thesis of zero-downtime deployment failures&lt;/li&gt;
&lt;li&gt;See the enforcement gap that let a smaller version of the same incident happen twice — and why process-only guardrails (PR review, runbooks) fail under deploy pressure&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Story
&lt;/h2&gt;

&lt;p&gt;The cluster was OpenShift on AWS (ROSA-class multi-tenant), shared across several logistics sub-teams. The deploy was routine — Argo CD-driven rolling updates, "true zero-downtime" by design, database migrations bundled into the same release as the application code because that's how the pipeline had always worked.&lt;/p&gt;

&lt;p&gt;A developer added a &lt;code&gt;NOT NULL&lt;/code&gt; column to a high-traffic tracking table. No default value. It passed review. It shipped on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;The migration ran first and succeeded — that part of the pipeline worked exactly as designed. Then the rolling update started replacing pods, and the old-version pods, which had no idea the new column existed, tried to write to the table the way they always had. Every write failed. Checkout and tracking went dark in about 45 seconds. The rolling update itself stalled, because the new pods — which did know about the column — were crash-looping on readiness checks for an unrelated reason further down the deploy sequence.&lt;/p&gt;

&lt;p&gt;The first response was the one that felt safest: hit rollback in Argo CD. It reverted the application image cleanly. It did nothing to the database. GitOps rollback and state rollback are not the same operation, and nobody in the room had internalized that distinction under pressure. The schema was still broken. The old code, now running again, still couldn't write to a table with a &lt;code&gt;NOT NULL&lt;/code&gt; column it didn't know about.&lt;/p&gt;

&lt;p&gt;Real recovery took four hours and involved a DBA forcing the lock. The stuck &lt;code&gt;ALTER TABLE&lt;/code&gt; was holding an exclusive lock that the rollback had no way to release, so it had to be killed manually. Deployments were scaled to zero — a hard maintenance window, the thing zero-downtime deploys exist to avoid. Then the constraint itself came out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;tracking&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;new_feature_id&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system came back on the old code. Not a rollback in the sense anyone wanted to write down — an abandonment of the release, with the schema stripped back to something the old code could live with.&lt;/p&gt;

&lt;p&gt;The blast radius didn't stop at the tracking table. Tracking pods, failing every write, spent CPU cycles reconnecting and retrying. That CPU pressure landed on shared compute nodes and starved adjacent microservices — teams with no relationship to the tracking database, on a cluster where compute was shared even though data stores weren't. A schema problem became a noisy-neighbor incident for services that never touched the broken table.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3iobt7txavydcp7jbth.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3iobt7txavydcp7jbth.png" alt="Incident Blast Radius" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Platform engineers running shared OpenShift clusters feel a specific version of this pain: zero-downtime deployment tooling — Argo CD, blue/green traffic splitting, rolling updates — was built to manage container lifecycle. None of it understands database schema state, and most teams don't notice the gap until a migration goes wrong mid-rollout.&lt;/p&gt;

&lt;p&gt;The cost here wasn't abstract. It was 45 seconds of hard outage, a four-hour manual recovery involving a DBA killing a stuck lock, a full release abandoned back to the old code, and a cross-team incident on services that shared nothing but compute. The failure mode that made it worse than a simple bad deploy: Argo CD's rollback gave the &lt;em&gt;appearance&lt;/em&gt; of a safety net. It reverted exactly what it manages — the application image — and nothing it doesn't — the schema. Trusting that rollback as a complete safety mechanism is what turned a bad migration into a four-hour incident instead of a thirty-second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Existing Approaches Fall Short
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Coupling the migration to the code release.&lt;/strong&gt; This is what the team was doing before the incident, and it's still the default in most CI/CD templates: migration and application deploy in the same Argo CD sync. It works exactly as long as every migration in every release is backward-compatible with the previous code version — which nobody enforces until the first time it isn't. &lt;a href="https://dev.to/posts/pitw-01-zero-downtime-deployments/"&gt;Zero-Downtime Deployments with GitHub Actions and Feature Flags&lt;/a&gt; solved this coupling problem for application code years ago in this series. Nobody had solved it for schema — until this incident forced the question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting GitOps rollback as schema rollback.&lt;/strong&gt; Argo CD reverts the manifests it manages. A database schema change made via a &lt;code&gt;PreSync&lt;/code&gt; hook, a Job, or a manual &lt;code&gt;ALTER TABLE&lt;/code&gt; is not tracked as part of the Argo CD-managed state in the same way — reverting the &lt;code&gt;Application&lt;/code&gt; to a prior Git revision does not undo a completed &lt;code&gt;ALTER TABLE&lt;/code&gt;. Teams that haven't hit this yet generally assume rollback is symmetric. It isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process-only migration review.&lt;/strong&gt; PR review and a runbook checklist catch a &lt;code&gt;NOT NULL&lt;/code&gt; without a default most of the time — which is exactly the problem. "Most of the time" is not an enforcement mechanism, and the honest gap section below covers what happened when this exact review process missed the same class of bug a second time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5iid5xm7nd1smmms6bpo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5iid5xm7nd1smmms6bpo.png" alt="Architecture Diagram" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram makes one design decision visible: the migration path and the application release path share a pipeline trigger but never share a credential, a lock scope, or a rollback mechanism. In production GitOps, Argo CD's &lt;code&gt;PreSync&lt;/code&gt; hook — not Sync Waves — is what enforces "schema before code" ordering. &lt;code&gt;PreSync&lt;/code&gt; alone is sufficient when there's only one dependency to sequence (migration before app rollout). The companion lab validates the same Jobs, role split, and batched backfill on ROSA HCP via phase scripts; wire those Jobs as &lt;code&gt;PreSync&lt;/code&gt; hooks when you put the pattern behind Argo CD.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Control plane:&lt;/strong&gt; Argo CD (production) or &lt;code&gt;run-phase.sh&lt;/code&gt; Jobs (lab), orchestrating expand → backfill → contract under a dedicated migrator ServiceAccount.&lt;br&gt;
&lt;strong&gt;Data plane:&lt;/strong&gt; Postgres (RDS in production; in-cluster Postgres in the ROSA HCP lab) and three database roles that gate access — &lt;code&gt;db_ddl&lt;/code&gt; (Data Definition Language — schema changes: &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;), &lt;code&gt;db_dml&lt;/code&gt; (Data Manipulation Language — row-level writes: &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;), and &lt;code&gt;db_app&lt;/code&gt; (application runtime).&lt;br&gt;
&lt;strong&gt;Blast radius:&lt;/strong&gt; bounded by role separation. Even a fully compromised application pod, using the app/DML credential, has no path to &lt;code&gt;ALTER TABLE&lt;/code&gt;. The DDL credential exists in exactly one place — the expand/contract Job's mounted secret — and nowhere else in the cluster.&lt;/p&gt;

&lt;p&gt;OpenShift/Kubernetes specifics: the &lt;code&gt;PreSync&lt;/code&gt; hook is an annotation on the Job manifest, not a Sync Wave. RBAC is a &lt;code&gt;ServiceAccount&lt;/code&gt; with a local &lt;code&gt;RoleBinding&lt;/code&gt; only — no &lt;code&gt;ClusterRoleBinding&lt;/code&gt;, no secret &lt;code&gt;get&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt; on the migrator Role (creds arrive via &lt;code&gt;envFrom.secretRef&lt;/code&gt; at pod create time).&lt;/p&gt;


&lt;h2&gt;
  
  
  How It Works: Step by Step
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;ROSA HCP (or ARO) with &lt;code&gt;oc&lt;/code&gt; logged in — pattern validated on ROSA HCP workers&lt;/li&gt;
&lt;li&gt;Postgres reachable from the cluster (lab: in-cluster Deployment; production: RDS / Azure DB in the same VPC)&lt;/li&gt;
&lt;li&gt;Three application-level DB roles: &lt;code&gt;db_ddl&lt;/code&gt; (expand/contract), &lt;code&gt;db_dml&lt;/code&gt; (backfill), &lt;code&gt;db_app&lt;/code&gt; (runtime)&lt;/li&gt;
&lt;li&gt;Optional: External Secrets Operator + AWS Secrets Manager / Azure Key Vault &lt;code&gt;SecretStore&lt;/code&gt; (lab default uses fallback Secrets; &lt;code&gt;USE_ESO=1&lt;/code&gt; swaps without changing Job specs)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;oc&lt;/code&gt; CLI matching cluster version&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 1 — Expand (nullable column, decoupled from code)
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- EXPAND / DDL — nullable only. Safe under traffic with old code still writing.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;tracking&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;new_feature_id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The column ships with no &lt;code&gt;NOT NULL&lt;/code&gt; constraint and no default. This is the entire point of Expand — old code, which has never heard of this column, keeps working against the same table without modification. The companion lab demonstrates the same Expand shape on an &lt;code&gt;orders&lt;/code&gt; table: nullable &lt;code&gt;customer_first_name&lt;/code&gt; / &lt;code&gt;customer_last_name&lt;/code&gt;, plus an empty &lt;code&gt;order_items&lt;/code&gt; table.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2 — Run the migration as a Job (PreSync in GitOps)
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Job&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-migrate-expand&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-migration&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-migrate&lt;/span&gt;
    &lt;span class="na"&gt;app.kubernetes.io/component&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expand&lt;/span&gt;
    &lt;span class="na"&gt;migration.phase&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expand&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Production GitOps wiring — lab applies this Job via run-phase.sh instead&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/hook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PreSync&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;ttlSecondsAfterFinished&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-migrate&lt;/span&gt;
        &lt;span class="na"&gt;migration.phase&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;expand&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-migrator&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;psql&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PGOPTIONS&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;search_path=app,public"&lt;/span&gt;
          &lt;span class="na"&gt;envFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-credentials-ddl&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sql&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/migrations&lt;/span&gt;
              &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/sh&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-ec&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;echo "Phase=expand user=${PGUSER} host=${PGHOST}"&lt;/span&gt;
              &lt;span class="s"&gt;for f in $(ls /migrations/*.sql | sort); do&lt;/span&gt;
                &lt;span class="s"&gt;echo "==&amp;gt; $(basename "$f")"&lt;/span&gt;
                &lt;span class="s"&gt;psql -v ON_ERROR_STOP=1 -f "$f"&lt;/span&gt;
              &lt;span class="s"&gt;done&lt;/span&gt;
              &lt;span class="s"&gt;echo "Phase expand complete"&lt;/span&gt;
              &lt;span class="s"&gt;psql -c "SELECT version, phase, applied_at, applied_by FROM app.schema_migrations ORDER BY applied_at;"&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sql&lt;/span&gt;
          &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;migration-sql-expand&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;PreSync&lt;/code&gt; guarantees this Job completes before Argo CD proceeds to sync the application &lt;code&gt;Deployment&lt;/code&gt;. No Sync Wave ordering needed for a single migration-before-code dependency. Expand SQL is idempotent (&lt;code&gt;ADD COLUMN IF NOT EXISTS&lt;/code&gt;) — if Expand fails mid-script, re-run the phase.&lt;/p&gt;

&lt;p&gt;Bookkeeping here is inline: each expand/contract SQL file ends with its own &lt;code&gt;INSERT INTO schema_migrations&lt;/code&gt; statement, since the whole file runs exactly once. Backfill can't do that — a single backfill file runs many times in a loop — so it uses a separate bookkeeping call instead, covered in Step 3.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3 — Batched backfill via Job (DML role)
&lt;/h3&gt;

&lt;p&gt;Backfill uses its own template (&lt;code&gt;openshift/jobs/backfill-job.yaml.tpl&lt;/code&gt;), not the shared expand/contract template from Step 2. Unlike a single-shot schema change, backfill has to move through a large table in bounded batches without holding a long lock — which is the direct lesson from the incident: an unbatched &lt;code&gt;UPDATE&lt;/code&gt; across every row takes an exclusive lock proportional to table size and holds it for the whole statement. On a small table that's invisible. On a high-traffic production table, it's the same failure mode that caused the original outage.&lt;/p&gt;

&lt;p&gt;Each backfill SQL file updates &lt;strong&gt;at most &lt;code&gt;:batch_size&lt;/code&gt; rows per execution&lt;/strong&gt; and reports how many it touched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- migrations/backfill/V003__backfill_split_names.sql&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_first_name&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;customer_last_name&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;batch_size&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt;
    &lt;span class="n"&gt;customer_first_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split_part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;customer_last_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;CASE&lt;/span&gt;
          &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;position&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
          &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="k"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;position&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;''&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Job's shell loop calls this file repeatedly via &lt;code&gt;psql -tA&lt;/code&gt;, reads the printed row count back into the shell, sleeps briefly between batches, and stops the moment a batch returns &lt;code&gt;0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;run_batched&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 n
  &lt;span class="k"&gt;while&lt;/span&gt; :&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;psql &lt;span class="nt"&gt;-tA&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;total &lt;span class="o"&gt;+&lt;/span&gt; n&lt;span class="k"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BATCH_PAUSE_SECONDS&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done
  &lt;/span&gt;psql &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /scripts/mark-migration-applied.sql
&lt;span class="o"&gt;}&lt;/span&gt;

run_batched &lt;span class="s2"&gt;"V003"&lt;/span&gt; &lt;span class="s2"&gt;"backfill"&lt;/span&gt; /migrations/V003__backfill_split_names.sql
run_batched &lt;span class="s2"&gt;"V004"&lt;/span&gt; &lt;span class="s2"&gt;"backfill"&lt;/span&gt; /migrations/V004__backfill_order_items.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BATCH_SIZE=500&lt;/code&gt;, &lt;code&gt;BATCH_PAUSE_SECONDS=0.25&lt;/code&gt; — small batches, brief lock, released between each one, rather than one lock held for the duration of the entire backfill.&lt;/p&gt;

&lt;p&gt;Progress is tracked in a &lt;code&gt;schema_migrations&lt;/code&gt; table, written once per file — after its loop completes, not once per batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- scripts/mark-migration-applied.sql&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;schema_migrations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="s1"&gt;'version'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'phase'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why a re-run after a partial failure doesn't reprocess files that already fully completed — the bookkeeping insert only happens once the loop for that file has already returned &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Backfill runs as &lt;code&gt;db_dml&lt;/code&gt; — it writes data, it never touches schema, so it never mounts the DDL secret.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Code pivot (N+1), only after 100% backfill confirmed
&lt;/h3&gt;

&lt;p&gt;New code deploys with defensive null-handling regardless of backfill status — a second line of defense, not a substitute for confirming backfill completion first. In the lab this is the dual-write → dual-read → cutover sequence (&lt;code&gt;WRITE_MODE&lt;/code&gt; / &lt;code&gt;READ_MODE&lt;/code&gt; on the app Deployment) before Contract is allowed to run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Contract (after old code is fully drained)
&lt;/h3&gt;

&lt;p&gt;Contract does two things, not one: it drops the legacy columns the migration was moving away from, and only then hardens the replacement columns with &lt;code&gt;NOT NULL&lt;/code&gt;. Doing both in the same phase — rather than dropping legacy columns separately — is deliberate: there's no reason to carry dead columns forward once nothing reads them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- migrations/contract/V005__contract_drop_legacy.sql&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Harden new columns once legacy is gone&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;customer_first_name&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;customer_last_name&lt;/span&gt;  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By this point every row has a value, so the &lt;code&gt;SET NOT NULL&lt;/code&gt; validates without holding an exclusive lock across a long table scan under load. The &lt;code&gt;DROP COLUMN&lt;/code&gt; calls are the less reversible half of this phase — there's no cheap rollback for a dropped column, only restore from backup or re-add it and re-backfill from scratch. Never contract until metrics show zero legacy reads.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DB-level role separation is the actual control, not a suggestion.&lt;/strong&gt; &lt;code&gt;db_ddl&lt;/code&gt; (schema owner, expand/contract) is used exclusively by DDL Jobs. &lt;code&gt;db_dml&lt;/code&gt; (SELECT/INSERT/UPDATE/DELETE only) runs backfill. &lt;code&gt;db_app&lt;/code&gt; is what application pods use. Even a fully compromised app pod has zero path to &lt;code&gt;ALTER TABLE&lt;/code&gt; — enforced at the database role level, not by convention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secret separation via ESO (or named Secrets).&lt;/strong&gt; Dual/triple credentials live in AWS Secrets Manager (or Azure Key Vault on ARO), synced into OpenShift via External Secrets Operator — the same multi-cloud ESO pattern covered in &lt;a href="https://dev.to/posts/secrets-management-multi-cloud-pipelines/"&gt;Secrets Management Across Multi-Cloud Pipelines&lt;/a&gt; — or, in the lab path, three explicitly named Secrets (&lt;code&gt;db-credentials-ddl&lt;/code&gt;, &lt;code&gt;db-credentials-dml&lt;/code&gt;, &lt;code&gt;db-credentials-app&lt;/code&gt;). The DDL secret is mounted only into expand/contract Jobs. Application Deployments never reference it. The lab's &lt;code&gt;USE_ESO=0&lt;/code&gt; fallback path carries the same RBAC scoping as the ESO path — role separation is enforced by which Secret each Job or Deployment mounts, not by which secret-management backend supplies it. Skipping ESO for local testing doesn't loosen the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RBAC scoped to independent audit identity.&lt;/strong&gt; &lt;code&gt;db-migrator&lt;/code&gt; exists so migration actions have their own traceable identity in the audit log, separate from the application's service account. It's bound to local &lt;code&gt;RoleBinding&lt;/code&gt;s only — Jobs, pods/logs, ConfigMaps in-namespace. No secret API read on the Role (injection is &lt;code&gt;envFrom&lt;/code&gt; at create time), no &lt;code&gt;ClusterRoleBinding&lt;/code&gt;, no cross-namespace access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What you gain / what you give up&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Decoupling migration from code deploy buys you the ability to ship a schema change independently of a release — and to have old code and new code coexist against the same schema without either one breaking. What you give up is deployment simplicity: a single feature now takes four release cycles to fully land (expand, backfill, pivot, contract) instead of one. For a small, fast-moving team, that overhead can feel disproportionate to the risk it prevents — until the first incident makes the tradeoff obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enforcement remains an open gap, not a solved problem.&lt;/strong&gt; The Expand/Contract pattern is a technical answer. It does not, by itself, stop someone from writing a &lt;code&gt;NOT NULL&lt;/code&gt; column with no default into an Expand script. Guardrail enforcement here has been process-only — PR review and a runbook — not automated. That's the honest limitation, and the next section covers what happened because of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;The guardrail enforcement was process-only, and that gap recurred in production: a rushed developer later slipped a &lt;code&gt;NOT NULL&lt;/code&gt; column into a Phase 1 script anyway, PR review missed it under the same time pressure that caused the original incident, and it triggered a smaller repeat incident.&lt;/p&gt;

&lt;p&gt;The fix isn't more review — it's automation. I'd stop relying on human discipline to catch an Expand script with a hard constraint in it, and instead force DB schema linting into CI, so a &lt;code&gt;NOT NULL&lt;/code&gt; without a default in an Expand migration fails the pipeline before it can be approved by a human at all. This isn't a hypothetical improvement — it's a lesson earned from watching the same category of mistake happen twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;At around 40+ microservices and roughly a dozen teams, the Expand/Contract discipline itself became the bottleneck rather than the database. A single feature deployment turned into a three-week, multi-release orchestration effort — expand, backfill, pivot, contract, each with its own review and release window. The failure mode at that scale wasn't technical. It was cognitive overhead and pipeline velocity friction: teams start looking for ways to skip phases under deadline pressure, which is exactly the condition that produced the repeat incident described above. It's the same category of discipline-at-scale problem covered from the infrastructure-state side in &lt;a href="https://dev.to/posts/terraform-managed-openshift-state/"&gt;Managed OpenShift, Lost State, and Daily Drift Checks&lt;/a&gt; — more moving parts don't just add work, they add places for the process itself to quietly fail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitOps rollback is not schema rollback&lt;/strong&gt; — Argo CD reverts what it manages (the application manifest); a database schema change is a separate operation with its own recovery path, and conflating the two turned a fixable bug into a four-hour incident&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand → batched backfill → pivot → contract&lt;/strong&gt; — nullable first, DML-only backfill in ~500-row batches, drop legacy columns and constrain only after old code is gone; &lt;code&gt;PreSync&lt;/code&gt; wires migration-before-code ordering when you run this under Argo CD&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process-only enforcement fails under deploy pressure&lt;/strong&gt; — the same class of bug (a hard constraint with no default) got through PR review twice; DB schema linting in CI is the fix, not more review discipline&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  GitHub Repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/agentic-devops/pipelineandprompts-labs/tree/main/pipelines-in-the-wild/06-database-migration-managed-openshift" rel="noopener noreferrer"&gt;agentic-devops/pipelineandprompts-labs — pipelines-in-the-wild/06-database-migration-managed-openshift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Validated on ROSA HCP (Expand/Contract Jobs, DDL/DML/app role split, batched backfill, dedicated &lt;code&gt;db-migrator&lt;/code&gt; SA). The lab uses an &lt;code&gt;orders&lt;/code&gt; table as a stand-in for the incident schema — same pattern, not a literal reproduction of the tracking table. Argo CD &lt;code&gt;PreSync&lt;/code&gt; annotations are the production GitOps wiring for those Jobs; the lab drives phases with &lt;code&gt;./scripts/run-phase.sh&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;Pipelines in the Wild #7 isn't scheduled yet. If you've hit a production war story worth covering — a specific incident, a specific fix, a specific thing you'd do differently — open an issue against the &lt;a href="https://github.com/agentic-devops/pipelineandprompts-labs" rel="noopener noreferrer"&gt;lab repo&lt;/a&gt; or flag it directly. That's genuinely where the next one starts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

</description>
      <category>argocd</category>
      <category>openshift</category>
      <category>databasemigrations</category>
      <category>pipelinesinthewild</category>
    </item>
    <item>
      <title>I Stopped Managing Kubernetes So I Could Start Managing Applications</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Sun, 26 Jul 2026 02:18:30 +0000</pubDate>
      <link>https://dev.to/agenticdevops/i-stopped-managing-kubernetes-so-i-could-start-managing-applications-4975</link>
      <guid>https://dev.to/agenticdevops/i-stopped-managing-kubernetes-so-i-could-start-managing-applications-4975</guid>
      <description>&lt;p&gt;&lt;em&gt;Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;🛠️ &lt;strong&gt;Pipelines in the Wild #5&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚡ Byte Size Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Understand why a Helm sub-chart with a hardcoded deprecated API can pass CI, deploy cleanly, and still crash production ten minutes later&lt;/li&gt;
&lt;li&gt;See why admission-time governance and a managed control plane solve two completely different problems — and why conflating them is how teams get burned&lt;/li&gt;
&lt;li&gt;Walk away with a clearer sense of where platform ownership should actually sit as an organization scales&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Story
&lt;/h2&gt;

&lt;p&gt;I was building an optimization model for scheduling — an internal Red Hat project, nothing exotic. Somewhere in the middle of it I noticed something uncomfortable: I wasn't spending my time on the application anymore. I was spending it on the platform underneath it. Roughly half my working hours were going into upgrade cycles — chasing down which APIs had quietly disappeared and which of my dependencies would break the moment the &lt;a href="https://dev.to/posts/kubernetes-at-scale/"&gt;Kubernetes&lt;/a&gt; cluster moved forward a minor version.&lt;/p&gt;

&lt;p&gt;The cluster in question was a large, multi-tenant OpenShift environment shared across several engineering teams. We didn't control what those teams deployed. Each had its own CI/CD pipeline, its own release cadence, its own tolerance for risk.&lt;/p&gt;

&lt;p&gt;The failure that made this real happened during what should have been a routine minor version upgrade. Our static Git repositories looked clean — we'd grepped them, audited them, signed off. What we hadn't accounted for was a third-party Helm chart with a hardcoded, deprecated API version buried inside a sub-chart, several layers deep. It didn't show up in any static scan because it was never in the Git source in a form our tooling could see — it was rendered dynamically, at deploy time, by Helm's templating engine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/posts/cicd-pipelines-code-to-realworld/"&gt;CI passed&lt;/a&gt;. The deploy succeeded. Ten minutes later, production pods started crashing — silently, with no pipeline failure and no obvious alert pointing at the cause. The control plane could no longer parse the schema for a resource that, as far as our source control was concerned, didn't exist.&lt;/p&gt;

&lt;p&gt;That was the moment the platform stopped being something I occasionally maintained and became something that was actively costing me the ability to do my actual job.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Platform engineers running multi-tenant OpenShift clusters feel this pain directly, and it isn't really about Kubernetes deprecating APIs — that's expected, documented, and version-scheduled. The real problem is that you cannot audit what you don't control, and in a shared cluster, tenant teams control their own manifests, their own Helm charts, and often their own third-party dependencies.&lt;/p&gt;

&lt;p&gt;Static auditing — grepping Git repos for deprecated API references before an upgrade — assumes the thing you're deploying is fully visible in source control. It isn't, once Helm templating, mutating webhooks, or dynamic chart composition enter the picture. The cost isn't abstract. It's engineering time spent firefighting instead of building. It's a 48-hour change-control &lt;a href="https://dev.to/posts/pitw-01-zero-downtime-deployments/"&gt;freeze window&lt;/a&gt; that becomes meaningless if something breaks anyway. And it's production incidents that look like mysterious pod crashes rather than the predictable, schedulable event an API deprecation actually is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Existing Approaches Fall Short
&lt;/h2&gt;

&lt;p&gt;We tried three things before landing on what actually worked, and each one failed against a specific constraint rather than being generically "not good enough."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dedicated pre-prod cluster running the target version early.&lt;/strong&gt; This sounds like the obvious answer, and it solved nothing for us. With a dozen independent teams deploying at will, a team could pass validation against pre-prod on Monday and push a completely different, unvalidated Helm chart to production on Wednesday — squarely inside the 48-hour freeze window. Pre-prod testing has no visibility into what changes between the test and the deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moving to a managed platform as the fix.&lt;/strong&gt; Red Hat OpenShift Service on AWS (ROSA) and Azure Red Hat OpenShift (ARO) genuinely solve control-plane toil — patching, minor version upgrades, master node lifecycle — by shifting that operational burden to the provider's SRE team. That's real, and it matters, but it does not solve this problem. If a tenant deploys a deprecated resource, a managed platform will still drop it on schedule during the upgrade, exactly like a self-managed cluster would. You'd be paying a premium for control-plane operations and still hitting the same silent runtime failure, because managed platforms don't rewrite your application manifests. They manage the masters, not the tenants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI-only static scanning against Git.&lt;/strong&gt; This is what we tried first, and it's what failed in the incident above. These tools only see what's committed. They're blind to Helm templates that render dynamically, mutating webhooks that alter objects after submission, and direct overrides developers reach for during incidents. The deprecated API in our failure never existed in a form static scanning could catch.&lt;/p&gt;

&lt;p&gt;What all three approaches share is a gap between what we could inspect and what actually got submitted to the cluster. Closing that gap meant moving enforcement to the one place that sees every object regardless of where it came from: the moment it's admitted, on the live cluster.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpipelineandprompts.com%2Fimages%2Fdiagrams%2Fi-stopped-managing-kubernetes-architecture.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpipelineandprompts.com%2Fimages%2Fdiagrams%2Fi-stopped-managing-kubernetes-architecture.png" alt="Admission Control Flow" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We adopted OPA Gatekeeper as an admission-time gate — a control that sits between anything being submitted to the cluster and the API server itself, evaluating the object as it actually arrives rather than as it appears in Git. That distinction is the whole point. It doesn't matter whether a manifest came from a clean pull request, a Helm sub-chart three layers deep, or a developer running a command by hand during an incident — everything crosses the same checkpoint before it's persisted.&lt;/p&gt;

&lt;p&gt;We deliberately didn't configure this as one global switch. Application namespaces were set to fail open — if the admission control itself became unreachable, deploys would still go through rather than grinding every tenant to a halt. The &lt;code&gt;kube-system&lt;/code&gt; namespace was set to fail closed, because a control-plane-critical failure was a risk we were willing to accept blocked deploys to avoid. That split existed because a single global policy in either direction was wrong for at least one half of the cluster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpipelineandprompts.com%2Fimages%2Fdiagrams%2Fi-stopped-managing-kubernetes-control-plane-split.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fpipelineandprompts.com%2Fimages%2Fdiagrams%2Fi-stopped-managing-kubernetes-control-plane-split.png" alt="Control Plane vs. Admission Governance" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The decision above took us longer to get right than the technical architecture did: separating who manages the control plane from who governs what gets deployed onto it. A managed platform like ROSA or ARO sits entirely on the control-plane side of that line. Admission governance sits on the other side, and it doesn't move just because you change who's patching the masters.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The mechanism itself is simple to describe even though getting it right operationally wasn't. Every object submitted to the cluster — however it arrived — passes through the admission gate before the API server accepts it. If the object matches something on our list of deprecated or removed APIs, it's rejected on the spot, and the team that submitted it gets a clear, immediate failure instead of a clean deploy that quietly breaks ten minutes later.&lt;/p&gt;

&lt;p&gt;Running this in production for a while surfaced something we didn't expect. A handful of teams found a way around it — not maliciously, just by submitting resources against an older version of the same API. Kubernetes itself has a conversion mechanism that will silently upgrade an older-format object after it's been admitted, and our check was evaluating the object as submitted, not as it ended up after conversion. The check passed on a value that was about to be rewritten underneath it.&lt;/p&gt;

&lt;p&gt;We caught this with a second, entirely separate tool — kubent — running on a schedule against the live state of the cluster, not against Git and not as part of the admission path. It doesn't prevent anything. It tells you, after the fact, what got through anyway. That pairing turned out to matter more than either control on its own: one thing stopping bad objects at the door, and something completely independent double-checking what actually ended up living in the cluster.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security and Operational Considerations
&lt;/h2&gt;

&lt;p&gt;Fail-open is a real availability decision, and it comes with a real cost. Because application namespaces are configured to let requests through if the admission control is unreachable, that unreachability becomes something an attacker — or just a badly behaved workload — could try to trigger deliberately. Flood the admission control with large enough or complex enough requests, push it past its timeout, and for a window, everything gets admitted unvalidated. That's not a hypothetical. It's a direct consequence of choosing availability over strict enforcement, and it's the tradeoff you're accepting the moment you set that policy.&lt;/p&gt;

&lt;p&gt;The string-matching gap described above is the other side of the same coin. The admission check was doing exactly what it was configured to do — it just wasn't looking at the version of the object that eventually mattered. That's not a bug in the tool. It's a mismatch between what got inspected and what got persisted, and it's the kind of gap that only shows up once you're running this against real, adversarial-enough traffic in a large, shared environment.&lt;/p&gt;

&lt;p&gt;Whatever runs the admission check needs broad read access across the cluster to do its job, which means it's also a meaningful blast radius if it's ever compromised. That's not a reason not to run it. It's a reason to treat changes to its configuration with the same scrutiny you'd apply to any other cluster-wide permission change, not as routine tooling maintenance.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;A few things stopped working cleanly as this scaled past our original setup, and none of them were the technology itself.&lt;/p&gt;

&lt;p&gt;The notifications became noise. Every denial generated an alert in a shared channel, and once teams saw enough of them, they stopped reading the channel at all — which meant the alerts that actually mattered started getting missed along with everything else. That's not a tooling failure. It's what happens to any high-volume signal that isn't triaged deliberately.&lt;/p&gt;

&lt;p&gt;The deny-list itself became a maintenance burden once we were running more than a handful of clusters. Kubernetes deprecates APIs on a rolling schedule across minor versions, and once different clusters are sitting on different versions — which happens naturally when every business unit has its own freeze window — a single list can't be correct everywhere at once. What's deprecated on one cluster might still be perfectly valid on another.&lt;/p&gt;

&lt;p&gt;And the namespace classification that the whole fail-open/fail-closed split depended on started drifting. New platform-critical components — new operators, new ingress layers — landed in namespaces that weren't &lt;code&gt;kube-system&lt;/code&gt;, and nobody went back and reclassified them. They silently inherited the fail-open behavior meant for ordinary application workloads, which defeats the entire point of the split. A one-time, manually maintained classification doesn't hold up as an organization keeps growing.&lt;/p&gt;

&lt;p&gt;If we were building this again today, we'd move away from the string-matching approach entirely and toward something that evaluates the actual resolved structure of an object rather than a single field that can be silently rewritten out from under the check. That closes the exact gap kubent had to compensate for, by design, instead of needing a second tool to catch what the first one missed.&lt;/p&gt;

&lt;p&gt;There's a broader point in all of this that's easy to miss until you've lived through it: putting an admission control in front of your cluster doesn't build a wall around it. It inserts a live, synchronous dependency directly into the control plane's request path. Once that exists, the health of your cluster isn't just a function of etcd and the API server anymore. It's also a function of how quickly that dependency responds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static auditing has a real blind spot&lt;/strong&gt; — anything rendered at deploy time, rather than committed to Git, is invisible to source-based scanning no matter how disciplined the process&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A managed control plane and admission governance solve different problems&lt;/strong&gt; — offloading upgrade toil to ROSA or ARO doesn't remove the need to govern what tenants actually deploy, and the two decisions shouldn't be conflated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevention and detection work best as separate layers&lt;/strong&gt; — an admission-time check can have real gaps, and a second, independent tool watching live cluster state is often what actually catches them&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  GitHub Repo
&lt;/h2&gt;

&lt;p&gt;This article documents an internal governance decision rather than a shareable implementation — there's no companion repository for this one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;If the alert-fatigue problem in this article sounds familiar, the next stop in the series is worth a read: &lt;strong&gt;&lt;a href="https://dev.to/posts/retry-logic-and-tiered-alerting-github-actions/"&gt;Retry Logic and Tiered Alerting in GitHub Actions&lt;/a&gt;&lt;/strong&gt; — a closer look at the same "signal drowning in noise" problem, this time from the CI/CD pipeline side rather than the admission-control side.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gatekeeper</category>
      <category>openshift</category>
      <category>admissioncontrol</category>
      <category>pipelinesinthewild</category>
    </item>
    <item>
      <title>Build an AI Incident Triage Workflow in n8n with MCP</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:19:12 +0000</pubDate>
      <link>https://dev.to/agenticdevops/build-an-ai-incident-triage-workflow-in-n8n-with-mcp-4kef</link>
      <guid>https://dev.to/agenticdevops/build-an-ai-incident-triage-workflow-in-n8n-with-mcp-4kef</guid>
      <description>&lt;p&gt;🤖 &lt;em&gt;AI in the Stack #6&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚡ Byte Size Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wire n8n to an MCP (Model Context Protocol) server over Streamable HTTP and a RAG (Retrieval-Augmented Generation) pipeline to build an automated incident triage workflow — runbook lookup and live cluster diagnostics assembled before a human opens a single dashboard&lt;/li&gt;
&lt;li&gt;The moment you place an LLM agent node inside an n8n workflow, execution becomes non-deterministic — the model decides how many tool calls to make, so your timeout and call limits must assume worst case, not average case&lt;/li&gt;
&lt;li&gt;Start with hard execution limits (5-minute timeout, 2-3 max calls per run) and loosen as you validate — a runaway workflow with no bounds will exhaust API quotas and flood notification channels before you notice&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Story
&lt;/h2&gt;

&lt;p&gt;I started using n8n because I watched a teammate build a workflow in twenty minutes that would have taken me a day to write in Python. The visual canvas, the drag-and-drop nodes, the instant execution feedback — it made automation feel fast. So I started experimenting.&lt;/p&gt;

&lt;p&gt;My first real workflows were personal — a financial portfolio tracker and a job search tool that scraped multiple sites. I pointed the job scraper at five websites simultaneously. All five came back with 401 errors — rate limited or blocked. The workflow had no error handling, no backoff, no maximum retry count. It kept firing. I only found out because I happened to check the n8n execution log in the UI and saw a wall of failed runs. No notification had told me anything was wrong.&lt;/p&gt;

&lt;p&gt;Nothing critical broke. But five endpoints was enough to demonstrate the failure mode. You don't need fifty to create a problem when there's no bound on execution.&lt;/p&gt;

&lt;p&gt;After several iterations on personal projects, I built something with real operational value: an &lt;a href="https://github.com/nedoshi/openshift-mcp-sre-tools" rel="noopener noreferrer"&gt;MCP server for OpenShift SRE (Site Reliability Engineering) diagnostics&lt;/a&gt;. It exposes 9 read-only tools for cluster health, failing pods, events, storage, and CrashLoopBackOff diagnostics across ARO (Azure Red Hat OpenShift), ROSA HCP (Red Hat OpenShift Service on AWS, Hosted Control Plane), and OSD-GCP (OpenShift Dedicated on Google Cloud). Paired it with a RAG knowledge base loaded with runbooks and SOPs. Both worked as standalone tools.&lt;/p&gt;

&lt;p&gt;The question was obvious: what happens when an alert fires and both tools run automatically, in coordination, before I've opened a single dashboard? That's what n8n was for — the orchestration layer that connects the pieces this series has built.&lt;/p&gt;

&lt;p&gt;What I didn't expect was how fundamentally the workflow would change once I put an LLM agent inside it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Every component built in this series so far — RAG runbook retrieval (&lt;a href="https://pipelineandprompts.com/posts/ai-in-the-stack-02-rag-runbooks/" rel="noopener noreferrer"&gt;Article 02&lt;/a&gt;), MCP cluster diagnostics (&lt;a href="https://pipelineandprompts.com/posts/mcp-server-architecture-platform-engineering-kubernetes/" rel="noopener noreferrer"&gt;Article 03&lt;/a&gt;), versioned prompts (Article 04), provider abstraction (&lt;a href="https://pipelineandprompts.com/posts/swapping-llm-providers-without-rewriting-stack/" rel="noopener noreferrer"&gt;Article 05&lt;/a&gt;) — is something a human calls manually. An engineer gets an alert, opens a terminal, queries the RAG pipeline, checks the cluster, cross-references the runbook, and posts a summary to Slack. That process takes 15-30 minutes per incident, and it requires the engineer to know which tools to call in which order.&lt;/p&gt;

&lt;p&gt;The orchestration gap isn't a tooling problem. Every individual tool works. The gap is that nothing connects them into an automated response that fires before the engineer has context-switched from whatever they were doing.&lt;/p&gt;

&lt;p&gt;But there's a deeper problem. Traditional workflow automation — a webhook triggers a script, the script runs steps in order, the output is predictable — is deterministic. Step A always leads to Step B, the number of API calls is fixed, the execution time is bounded by design.&lt;/p&gt;

&lt;p&gt;Add an LLM agent to that workflow and determinism disappears. The agent decides how many MCP tools to call based on its interpretation of the input. It might call &lt;code&gt;get_cluster_health&lt;/code&gt; once, or it might call &lt;code&gt;get_failing_pods&lt;/code&gt;, then &lt;code&gt;get_events&lt;/code&gt;, then &lt;code&gt;diagnose_crashloop&lt;/code&gt; — three calls instead of one, against live cluster APIs, with execution time that varies per run. Your workflow looks the same on the canvas. Its runtime behavior is different every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Existing Approaches Fall Short
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Custom Python scripts.&lt;/strong&gt; You can wire RAG + MCP + Slack in an async Python service. I've done it. Managing concurrent HTTP calls, retry logic, error handling, and state across a multi-step async chain produces code that's hard to debug and hard to hand off to a teammate. It's also invisible when it fails silently at 3am. n8n's visual execution log shows exactly which node failed and what data it received — that visibility alone justified the switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions.&lt;/strong&gt; Good for CI/CD, wrong for real-time event response. Actions are stateless, trigger on repository events, and have cold-start latency. An incident triage workflow needs to fire on a Prometheus webhook, hold state between steps, and complete in seconds to minutes — not wait for a runner to spin up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zapier or cloud-hosted automation.&lt;/strong&gt; Not self-hostable. Your workflow data, your cluster diagnostics, your runbook content — all leave your infrastructure boundary. For platform engineering on OpenShift, that's a non-starter in most enterprise environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;n8n without guardrails.&lt;/strong&gt; This is the approach I started with, and it's the one that produced the runaway loop. n8n makes building workflows remarkably easy. That ease is precisely why it needs execution limits, error workflows, and bounded retries before the workflow goes anywhere near a production alerting pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fln5zutji2c37twmw23m0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fln5zutji2c37twmw23m0.png" alt="n8n Platform Engineering Automation"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The architecture connects four components through n8n as the orchestration layer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger.&lt;/strong&gt; A webhook endpoint in n8n receives an alert payload. For the POC, this was manually triggered with &lt;code&gt;curl&lt;/code&gt;. The production target is a Prometheus Alertmanager webhook — configuration provided below, but not yet tested against a live Alertmanager.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG Service.&lt;/strong&gt; The runbook retrieval pipeline from Article 02. n8n calls it via HTTP Request node, passing the alert description as the query. Returns relevant runbook content and source references.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP Server.&lt;/strong&gt; The OpenShift SRE diagnostic server, connected via n8n's community MCP client node over Streamable HTTP transport. The MCP spec deprecated the older SSE (Server-Sent Events) transport in its 2025-03-26 revision, so this article builds on Streamable HTTP throughout — see the transport note in Step 1 if your server hasn't migrated yet. It provides live cluster state — failing pods, recent events, cluster health — without custom REST wrappers or protocol translation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM (Vertex AI).&lt;/strong&gt; AI analysis runs through Google Vertex AI, configured using n8n's built-in GCP credentials. I started with Ollama locally but the 16GB hardware constraint made it unusable for real workflows — same limitation from Article 05. Vertex AI through GCP was the path that worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack.&lt;/strong&gt; The output destination. A single message combining runbook guidance and live cluster state, posted before a human has opened a dashboard.&lt;/p&gt;

&lt;p&gt;The three lookups — runbook retrieval, failing-pod check, and recent-events check — run concurrently instead of one waiting on another. That's a deliberate change from an earlier version of this workflow that routed both MCP calls through a single combined node. Two independent MCP Client nodes, one per tool, can execute in parallel — one node handling both tools couldn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works: Step by Step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;n8n self-hosted instance (local or on-cluster)&lt;/li&gt;
&lt;li&gt;The community node package &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/n8n-nodes-mcp" rel="noopener noreferrer"&gt;n8n-nodes-mcp&lt;/a&gt;&lt;/strong&gt; installed in that instance (Settings → Community Nodes → Install → &lt;code&gt;n8n-nodes-mcp&lt;/code&gt;) — not n8n's built-in MCP Client Tool node, which only works as an AI Agent sub-node and can't be wired the way this workflow needs. This is a self-hosted-only step; it's not available on n8n Cloud.&lt;/li&gt;
&lt;li&gt;MCP server running with Streamable HTTP transport (&lt;a href="https://github.com/nedoshi/openshift-mcp-sre-tools" rel="noopener noreferrer"&gt;openshift-mcp-sre-tools&lt;/a&gt; — confirm which transport your deployment currently runs; the MCP spec deprecates SSE, so verify before assuming Streamable HTTP support)&lt;/li&gt;
&lt;li&gt;RAG service running (Article 02 pipeline)&lt;/li&gt;
&lt;li&gt;Google Cloud project with Vertex AI API enabled&lt;/li&gt;
&lt;li&gt;GCP service account credentials configured in n8n&lt;/li&gt;
&lt;li&gt;Slack workspace with an incoming webhook or bot token&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1 — Connect n8n to the MCP Server
&lt;/h3&gt;

&lt;p&gt;n8n's built-in &lt;strong&gt;MCP Client Tool&lt;/strong&gt; node connects to an MCP server, but it's a sub-node that plugs into an AI Agent's tool input. It isn't a standalone node with regular output connections you can wire directly into a Merge step. This workflow calls &lt;code&gt;get_failing_pods&lt;/code&gt; and &lt;code&gt;get_events&lt;/code&gt; deterministically, outside of agent reasoning, so it uses the community &lt;strong&gt;n8n-nodes-mcp&lt;/strong&gt; package's standalone MCP Client node instead, which supports direct calls with normal input/output connections. Install that package (see Prerequisites) before following the steps below — the built-in node won't let you wire the canvas this way.&lt;/p&gt;

&lt;p&gt;A separate transport note, since it affects which credential fields you fill in: the MCP spec deprecated the HTTP+SSE transport in its 2025-03-26 revision in favor of &lt;strong&gt;Streamable HTTP&lt;/strong&gt;. Client-side SSE support has been narrowing since. Use Streamable HTTP for any new MCP server; only fall back to SSE if you're connecting to an existing server that hasn't migrated yet.&lt;/p&gt;

&lt;p&gt;Unlike an HTTP Request node, the server endpoint isn't a field on the MCP Client node itself — it lives in a &lt;strong&gt;credential&lt;/strong&gt;, shared across every MCP Client node that uses it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In n8n's &lt;strong&gt;Credentials&lt;/strong&gt; panel, create a new credential of type &lt;strong&gt;MCP Client API&lt;/strong&gt; (this type appears once the community package is installed). It offers a connection-type selector — choose &lt;strong&gt;HTTP Streamable&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Set the &lt;strong&gt;HTTP Streamable URL&lt;/strong&gt; to your MCP server's address, e.g. &lt;code&gt;http://platform-mcp-server:8080/mcp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Save it with a name you'll recognize — this article uses &lt;code&gt;Platform MCP Server (Streamable HTTP)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then, for each MCP tool you want to call, add an &lt;strong&gt;MCP Client&lt;/strong&gt; node (from &lt;code&gt;n8n-nodes-mcp&lt;/code&gt;) to the canvas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resource:&lt;/strong&gt; Tool&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operation:&lt;/strong&gt; Execute Tool&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool Name:&lt;/strong&gt; the specific tool to call, e.g. &lt;code&gt;get_failing_pods&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential:&lt;/strong&gt; the &lt;code&gt;Platform MCP Server (Streamable HTTP)&lt;/code&gt; credential from above&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This workflow needs two calls — &lt;code&gt;get_failing_pods&lt;/code&gt; and &lt;code&gt;get_events&lt;/code&gt; — so add two separate MCP Client nodes, both pointed at the same credential, each with its own &lt;strong&gt;Tool Name&lt;/strong&gt;. That's also what makes the parallel-execution pattern in Step 3 possible: two independent nodes can run concurrently, where one combined "call whatever tools you need" node couldn't.&lt;/p&gt;

&lt;p&gt;The MCP server runs as a separate process — either on your local machine during development or as a pod on OpenShift for production. n8n connects to it as a network service over HTTP, not as a spawned child process. This means the MCP server has its own lifecycle, scaling, and RBAC independent of n8n.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Confirmed: this workflow uses the community &lt;code&gt;n8n-nodes-mcp&lt;/code&gt; package's standalone MCP Client node, not the built-in &lt;code&gt;n8n-nodes-langchain.mcpClientTool&lt;/code&gt;. That's the only one of the two that supports the direct, deterministic tool calls this workflow relies on — the built-in node is agent-only and can't be wired into a Merge step this way.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Configure Vertex AI Credentials
&lt;/h3&gt;

&lt;p&gt;In n8n's &lt;strong&gt;Credentials&lt;/strong&gt; panel, add a &lt;strong&gt;Google Cloud&lt;/strong&gt; credential:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Upload or paste your GCP service account JSON key&lt;/li&gt;
&lt;li&gt;The credential provides access to Vertex AI endpoints in your project&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add a &lt;strong&gt;Google Vertex Chat Model&lt;/strong&gt; node to the canvas (n8n's docs also refer to it as "Google Vertex AI Chat Model" — same node):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project ID:&lt;/strong&gt; your GCP project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Region:&lt;/strong&gt; &lt;code&gt;us-central1&lt;/code&gt; (or your preferred region) — note that as of this writing n8n's Vertex node doesn't expose a region/zone field directly in all versions; if yours doesn't, region falls back to your GCP project default, which can cause quota errors in unexpected regions. Check your installed version's node parameters before assuming this field exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model:&lt;/strong&gt; the model available in your Vertex AI project&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This replaces any direct Anthropic or OpenAI API call. All LLM requests route through your GCP project, using your organization's billing, IAM policies, and data residency controls.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Build the Workflow
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Trigger.&lt;/strong&gt; Add a &lt;strong&gt;Webhook&lt;/strong&gt; node as the entry point. For testing, trigger it manually with &lt;code&gt;curl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:5678/webhook/incident-triage &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "alerts": [{
      "status": "firing",
      "labels": {
        "alertname": "PodCrashLooping",
        "severity": "warning",
        "namespace": "production",
        "service": "payment-api"
      },
      "annotations": {
        "description": "Pod payment-api-7d4b8c6f5-x2k9m has restarted 5 times in 10 minutes"
      },
      "startsAt": "2026-07-20T14:32:00Z"
    }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production, configure Alertmanager to call this webhook — but note this was not tested against a live Alertmanager in the POC. This snippet shows the receiver only; it still needs a matching &lt;code&gt;route:&lt;/code&gt; block that selects this receiver by label match, or Alertmanager won't send anything to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# alertmanager.yml — production target (not yet tested)&lt;/span&gt;
&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n-platform-automation&lt;/span&gt;
    &lt;span class="na"&gt;webhook_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://your-n8n-instance/webhook/incident-triage'&lt;/span&gt;
        &lt;span class="na"&gt;send_resolved&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="c1"&gt;# route: block required elsewhere in the config to actually invoke this receiver, e.g.:&lt;/span&gt;
&lt;span class="c1"&gt;# route:&lt;/span&gt;
&lt;span class="c1"&gt;#   routes:&lt;/span&gt;
&lt;span class="c1"&gt;#     - receiver: n8n-platform-automation&lt;/span&gt;
&lt;span class="c1"&gt;#       matchers:&lt;/span&gt;
&lt;span class="c1"&gt;#         - severity =~ "warning|critical"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Parse the alert.&lt;/strong&gt; Add a &lt;strong&gt;Code&lt;/strong&gt; node to extract fields from the Alertmanager payload format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;alertname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alertname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deployment&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;startsAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startsAt&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Query the RAG pipeline.&lt;/strong&gt; Add an &lt;strong&gt;HTTP Request&lt;/strong&gt; node calling the RAG service from Article 02, and name it &lt;code&gt;RAG Query&lt;/code&gt; on the canvas to match the code references in later steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Method: POST
URL: http://runbook-rag-service:8080/query
Body (JSON):
{
  "question": "How do I troubleshoot {{ $json.alertname }} for {{ $json.service }}? {{ $json.description }}"
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Query the MCP server.&lt;/strong&gt; Add the two &lt;strong&gt;MCP Client&lt;/strong&gt; nodes configured in Step 1, both fed directly from &lt;code&gt;Parse Alert&lt;/code&gt; so they run in parallel rather than one waiting on the other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Name one &lt;code&gt;MCP Get Failing Pods&lt;/code&gt;, with &lt;strong&gt;Tool Name&lt;/strong&gt; set to &lt;code&gt;get_failing_pods&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Name the other &lt;code&gt;MCP Get Events&lt;/code&gt;, with &lt;strong&gt;Tool Name&lt;/strong&gt; set to &lt;code&gt;get_events&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Name them exactly this way — the code in the next steps references both nodes by these exact names, and n8n's &lt;code&gt;$('NodeName')&lt;/code&gt; expression syntax requires an exact match. Both pass the namespace from the parsed alert as the tool parameter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bounded conditional.&lt;/strong&gt; Add an &lt;strong&gt;IF&lt;/strong&gt; node to check whether the issue has already self-resolved before escalating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Condition: MCP response shows no failing pods in the namespace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;True branch: lightweight "auto-resolved" notification. False branch: full alert with runbook guidance and cluster state.&lt;/p&gt;

&lt;p&gt;This single conditional separates "automation that helps" from "automation that floods a channel with noise every time a transient blip self-corrects."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Format and post to Slack.&lt;/strong&gt; Add a &lt;strong&gt;Code&lt;/strong&gt; node to merge the RAG, failing-pods, and events responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ragAnswer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RAG Query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ragSources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RAG Query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failingPods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MCP Get Failing Pods&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;failing_pods&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recentEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MCP Get Events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recent_events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Parse Alert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failingPodsText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;failingPods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;failingPods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`• &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;None currently failing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`*&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alertname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;* — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)\n\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="s2"&gt;`*Runbook guidance:*\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ragAnswer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n_Source: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ragSources&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_\n\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="s2"&gt;`*Current cluster state:*\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failingPodsText&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
          &lt;span class="s2"&gt;`*Recent events:* &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;recentEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; events in last check`&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;[TECHNICAL FIX: the original code referenced &lt;code&gt;$json.alertname&lt;/code&gt; here, which only resolves against the item arriving at &lt;em&gt;this specific node&lt;/em&gt;. After the Merge and Analyze steps, that item is whatever those nodes output, not the original parsed alert. Referencing &lt;code&gt;$('Parse Alert').first().json&lt;/code&gt; explicitly is the correct n8n pattern for pulling data from an earlier node in the chain, regardless of what's passed through the nodes in between. It also originally referenced a single &lt;code&gt;$('MCP Query')&lt;/code&gt; node, from before the MCP call was split into two parallel nodes — updated to pull from &lt;code&gt;MCP Get Failing Pods&lt;/code&gt; and &lt;code&gt;MCP Get Events&lt;/code&gt; directly.]&lt;/p&gt;

&lt;p&gt;This is the payoff. The Slack message an on-call engineer receives contains grounded runbook guidance and live cluster state, assembled automatically, before they have opened a single tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — Set Execution Limits
&lt;/h3&gt;

&lt;p&gt;Three settings prevent the runaway loop failure mode:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execution timeout.&lt;/strong&gt; In n8n's Workflow Settings, set a maximum execution time. Start with 5 minutes for experimentation. The POC workflow took approximately 30 minutes end-to-end — mostly due to Kubernetes API query latency across multiple clusters via the MCP server. That's acceptable for a proof of concept. It is not acceptable for production incident response. Design around this latency before moving to production: limit the number of clusters queried per run, cache cluster state, or run MCP queries in parallel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry limits on HTTP nodes.&lt;/strong&gt; Use n8n's built-in retry configuration on HTTP Request nodes — not manual loops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Request node settings:
  Retry on Fail: enabled
  Max Tries: 3
  Wait Between Tries: 2000ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The built-in retry mechanism cannot become infinite. A manual loop with a Wait node and a conditional back-edge can — as I learned on the scraping workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error workflow.&lt;/strong&gt; In Workflow Settings, set an &lt;strong&gt;Error Workflow&lt;/strong&gt; — a separate, simple workflow that fires if the main workflow throws an exception. At minimum, have it post to a dedicated &lt;code&gt;#automation-errors&lt;/code&gt; Slack channel. A failure in your incident automation should never fail silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — Add Execution Logging
&lt;/h3&gt;

&lt;p&gt;Add a final &lt;strong&gt;Code&lt;/strong&gt; node that logs a structured summary of every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;alertname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Parse Alert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alertname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Parse Alert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;auto_resolved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MCP Get Failing Pods&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;failing_pods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;workflow_execution_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;[TECHNICAL FIX: the original code referenced &lt;code&gt;$json.alertname&lt;/code&gt; and &lt;code&gt;$json.is_healthy&lt;/code&gt;. &lt;code&gt;is_healthy&lt;/code&gt; was never set by any earlier node in this workflow — it would have thrown a runtime error or silently returned &lt;code&gt;undefined&lt;/code&gt; depending on n8n's expression handling. Both fields now pull from nodes that actually produced them.]&lt;/p&gt;

&lt;p&gt;This gives you the audit trail this series has insisted on since Article 03: every automated action should be traceable after the fact, not just visible while it's running.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security and Operational Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The MCP server must be read-only.&lt;/strong&gt; The &lt;a href="https://github.com/nedoshi/openshift-mcp-sre-tools" rel="noopener noreferrer"&gt;openshift-mcp-sre-tools&lt;/a&gt; server runs in read-only mode by default — all 9 tools are diagnostic. This is the design property that makes it safe to wire into n8n without additional guardrails. If those tools could modify cluster state, an LLM agent deciding to call them unpredictably would be a fundamentally different risk profile. Start read-only. Add write capabilities only when you have execution limits, approval gates, and rollback mechanisms in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GCP credentials in n8n need protection.&lt;/strong&gt; n8n stores credentials encrypted at rest, but the n8n instance itself becomes a high-value target. It holds GCP service account keys that access Vertex AI and potentially other GCP services. If n8n runs on-cluster, the namespace needs a NetworkPolicy restricting who can reach the n8n UI and API. If it runs locally, access is inherently restricted to the operator's machine. Scope the service account to a minimal custom IAM (Identity and Access Management) role — Vertex AI User at most, not Editor or Owner. A broadly-scoped key stolen from n8n's credential store is a much bigger blast radius than a Vertex-AI-only one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The webhook endpoint is an attack surface.&lt;/strong&gt; When n8n runs on-cluster with a webhook trigger, that endpoint needs to be reachable by Alertmanager but not by the public internet. Use OpenShift Routes with edge TLS termination (&lt;code&gt;spec.tls.termination: edge&lt;/code&gt;) and restrict access via NetworkPolicy. Authenticate the webhook — Alertmanager supports basic auth on webhook configs — so that arbitrary POST requests can't trigger your automation. Authentication alone doesn't bound volume, though. Also rate-limit the webhook, at the Route or via an n8n-side check. That way a real alert storm from a misconfigured Alertmanager rule can't multiply into dozens of concurrent LLM-agent executions and exhaust the same API quotas the execution limits in Step 4 are meant to protect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP server connection security.&lt;/strong&gt; The Streamable HTTP transport between n8n and the MCP server should run over TLS if they're on separate hosts. If both run in the same cluster, a ClusterIP Service with a NetworkPolicy restricting ingress to the n8n namespace is sufficient. If you're still running an MCP server on the deprecated SSE transport, the same TLS/NetworkPolicy guidance applies — but plan the migration to Streamable HTTP regardless, since SSE client support will keep narrowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the workflow sends externally.&lt;/strong&gt; The RAG query and the MCP cluster diagnostics — namespace names, pod names, event messages — are sent to Vertex AI for analysis. These aren't credentials, but they reveal internal infrastructure details. The same data leakage consideration from Article 05 applies: Vertex AI runs in your GCP project, which is better than a third-party provider API, but the data still leaves the OpenShift cluster boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit trail durability.&lt;/strong&gt; The execution logging in Step 5 captures a structured summary per run, but that's only useful as an audit trail if it's actually retained. n8n prunes execution data on a configurable schedule, and the default retention window is short. This automation should be auditable after the fact — it's an incident-response workflow touching production clusters. Explicitly configure execution data saving and retention in n8n's settings, or ship the Log Execution node's output to an external log store (e.g., OpenShift's log aggregation stack) instead of relying on n8n's own execution history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-tenant Slack exposure.&lt;/strong&gt; This workflow posts namespace names, pod names, and event messages to Slack. If the destination channel is shared across teams, or the alert can originate from any namespace on a multi-tenant cluster, that's cross-tenant data exposure through the notification — even though the MCP server itself is read-only and correctly scoped. Route notifications to a channel scoped to the alert's originating namespace or team, not one shared channel for every cluster the MCP server can see.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;30-minute latency is not production-ready.&lt;/strong&gt; The POC workflow took approximately 30 minutes end-to-end. The bottleneck was Kubernetes API queries across multiple clusters via the MCP server. For a proof of concept that validates the flow, this is acceptable. For an incident response workflow that needs to beat a human to the diagnosis, it's too slow. The production path requires limiting clusters per query, parallelizing MCP calls, or caching recent cluster state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-deterministic execution makes testing harder.&lt;/strong&gt; A traditional n8n workflow produces the same output for the same input every time. With an LLM agent node, the same alert payload can trigger different MCP tool call sequences, different analysis depth, and different token consumption per run. You can't write deterministic integration tests against this workflow. You test the bounds — does it stay within the timeout, does it stay within the call limit — not the exact output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transport choice affects concurrent-user scaling, but isn't the only bottleneck.&lt;/strong&gt; An earlier version of this workflow used the SSE transport, which holds one persistent connection open per workflow execution. Five engineers running concurrent workflows meant five persistent connections, and the MCP server's connection pool filled up under load. That specific problem is largely why the MCP spec deprecated SSE in favor of Streamable HTTP, which supports a stateless request/response mode designed for concurrent, load-balanced access — this article now builds on Streamable HTTP for that reason. What Streamable HTTP doesn't fix: every MCP call still triggers real Kubernetes API queries against real clusters, and cloud provider API throttling (Azure for ARO, AWS for ROSA) sits upstream of the transport layer entirely. Fixing the connection model doesn't fix cluster API rate limits. This architecture still works best for a single operator or a small team; at real scale, the Kubernetes API layer — not the MCP transport — is the first bottleneck you'll hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visual simplicity hides execution complexity.&lt;/strong&gt; The n8n canvas shows five or six nodes in a clean flow. What it doesn't show is the LLM agent making variable numbers of tool calls per run, each with its own latency, retry behavior, and failure mode. The canvas is the map, not the territory. The execution log is where you see what actually happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;Two things, both cheap to implement on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard execution limits before the first test run.&lt;/strong&gt; Five-minute timeout, 2-3 maximum tool calls per workflow execution. Not because those are the right numbers for production — I don't know the right numbers yet, and finding them is trial and error. But having any limit at all would have caught the runaway scraper in minutes instead of hours. Start tight, hit the limits during real runs, adjust based on what you observe. The important thing is that limits exist, not that they're perfect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design around latency from the start.&lt;/strong&gt; I accepted 30-minute workflow runs because I was validating the flow, not optimizing it. The honest lesson is that I should have instrumented execution time per node from the first run and identified the MCP cluster query bottleneck immediately. Then I could have made a conscious decision about whether to accept it or fix it, instead of discovering it casually after multiple runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails come before the happy path&lt;/strong&gt; — an execution timeout, retry limits, and a dedicated error workflow are what stop a runaway LLM agent from exhausting API quotas before anyone notices; set them before the first real test run, not after an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An LLM agent node breaks determinism, so design and test for the bounds, not the exact output&lt;/strong&gt; — the same alert payload can trigger different tool-call sequences and different execution times on different runs; verify the workflow stays within its timeout and call limits, not that it produces identical output every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure what you assume, especially latency&lt;/strong&gt; — a 30-minute POC run and an SSE connection-pool limit both turned out to be fixable (parallel MCP calls, migrating to Streamable HTTP), but only because I instrumented and looked at them directly instead of accepting "good enough for a proof of concept."&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  GitHub Repo
&lt;/h2&gt;

&lt;p&gt;Full implementation: &lt;a href="https://github.com/agentic-devops/pipelineandprompts-labs/tree/main/ai-in-the-stack/06-n8n-agentic-workflows" rel="noopener noreferrer"&gt;pipelineandprompts-labs/ai-in-the-stack/06-n8n-agentic-workflows&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Includes the importable n8n workflow and a demo MCP server and RAG service, so you can run the whole thing locally without a live OpenShift cluster or GCP project. Also includes the error workflow and setup instructions for swapping in the real services.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AI in the Stack #7 — Agentic AI Infrastructure: What It Takes to Do It Safely&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This series has built every component of an AI-assisted operations stack — retrieval, live cluster access, governed prompts, portable providers, and now orchestrated automation. The final article covers what it actually takes, organizationally and architecturally, before any of this earns real operational authority: approval gates, blast-radius limits, and the governance model that decides when automation acts without a human in the loop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>openshift</category>
      <category>incidentresponse</category>
      <category>aiinthestack</category>
    </item>
    <item>
      <title>Swapping LLM Providers Without Rewriting Your Stack</title>
      <dc:creator>Nerav Doshi</dc:creator>
      <pubDate>Sun, 12 Jul 2026 22:48:45 +0000</pubDate>
      <link>https://dev.to/agenticdevops/swapping-llm-providers-without-rewriting-your-stack-4mge</link>
      <guid>https://dev.to/agenticdevops/swapping-llm-providers-without-rewriting-your-stack-4mge</guid>
      <description>&lt;p&gt;🤖 &lt;em&gt;AI in the Stack #5&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚡ Byte Size Summary&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deploy a LiteLLM proxy on OpenShift to decouple your applications from individual LLM providers — switch or failover between models by changing a ConfigMap, not your code&lt;/li&gt;
&lt;li&gt;Inject cluster-specific context (API versions, cloud annotations, golden templates) before every prompt to prevent models from generating "plausible averages" that look correct but fail on your platform&lt;/li&gt;
&lt;li&gt;Validate every generated manifest with &lt;code&gt;oc apply --dry-run=server&lt;/code&gt; before applying — the cluster's API server catches structural errors that no amount of prompt engineering will prevent&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Story
&lt;/h2&gt;

&lt;p&gt;I was building a GenAI data poisoning demo for a proof of concept on ROSA (Red Hat OpenShift on AWS). The demo needed OpenShift-native manifests — Routes with TLS termination, proper SecurityContextConstraints, current API versions. I built it using Claude, and it worked. The model understood OpenShift. The manifests deployed cleanly. The demo ran on the cluster.&lt;/p&gt;

&lt;p&gt;Then I ran out of free credits.&lt;/p&gt;

&lt;p&gt;I was under a deadline, so I switched to OpenAI. The first manifest it generated used a Kubernetes Ingress instead of an OpenShift Route. The API versions referenced documentation that was two releases behind our cluster version. The YAML was syntactically valid. It was functionally wrong for OpenShift.&lt;/p&gt;

&lt;p&gt;I tried to fix it by researching the correct documentation through NotebookLM, then feeding corrections back into the model. When that didn't converge, I switched to Gemini. Gemini gave me enough directional guidance that I could manually correct the manifests — using my own OpenShift knowledge, not the model's.&lt;/p&gt;

&lt;p&gt;Two days later, I had a working demo again and ten-plus markdown files across my project. I couldn't tell which model had generated which file, which version was current, or which one had actually deployed successfully on the cluster. The project state had diverged so badly across three providers that rolling back to any previous working state was impossible — even if the credits had come back.&lt;/p&gt;

&lt;p&gt;My model selection strategy during those two days was trial and error. I switched between Sonnet and Opus without understanding the difference between them. If a model's output deployed on the cluster, I stuck with it for that session. Every new session was a fresh gamble.&lt;/p&gt;

&lt;p&gt;The ROSA cluster was my only validation oracle. If &lt;code&gt;oc apply&lt;/code&gt; succeeded, the manifest stayed. If it errored, I had the model strip the broken section out and try again.&lt;/p&gt;

&lt;p&gt;The failure wasn't switching providers. The failure was having no system in place that could make &lt;em&gt;any&lt;/em&gt; provider produce correct output — no context injection, no validation gate, no abstraction layer. The fix is designing the control plane first — so models can be wrong without causing damage.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;LLMs generate statistically averaged infrastructure artifacts. They don't know your cluster — they know &lt;em&gt;all clusters&lt;/em&gt;. The output is a blend of Kubernetes, OpenShift, ROSA, ARO (Azure Red Hat OpenShift), EKS (Amazon Elastic Kubernetes Service), and whatever else was in the training data. It looks right because it's an average of right things. An average of all correct configurations is correct for none of them.&lt;/p&gt;

&lt;p&gt;This manifests in three ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider coupling.&lt;/strong&gt; Your prompts are implicitly tuned to a specific model's training data and behavior. Switch providers — because of cost, credits, rate limits, or compliance — and the same prompt produces different output. Not always wrong output. Different output that may or may not be valid for your specific environment. You don't find out until you try to deploy it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent knowledge gaps.&lt;/strong&gt; A model that knows Kubernetes well may not know OpenShift. It generates a &lt;code&gt;networking.k8s.io/v1&lt;/code&gt; Ingress where &lt;code&gt;route.openshift.io/v1&lt;/code&gt; is what your cluster expects. It uses AWS load balancer annotations for a manifest targeting an ARO cluster. The output is valid YAML, valid Kubernetes — and wrong for your platform. There's no error message. The model doesn't know what it doesn't know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No validation boundary.&lt;/strong&gt; When a model generates a manifest, nothing in the default workflow checks whether it's correct for your cluster version, your cloud provider, or your installed operators. The human is the only validation layer — and under deadline pressure, the human trusts the output that looks right.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Existing Approaches Fall Short
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Just switch models."&lt;/strong&gt; This was my first instinct. Claude ran out of credits, try OpenAI. OpenAI broke things, try Gemini. Each switch introduced different knowledge gaps without solving the underlying problem: no model has complete, current knowledge of your specific platform. Switching providers is moving the problem, not fixing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Write better prompts."&lt;/strong&gt; Prompt engineering helps with reasoning tasks — structuring output, defining roles, specifying constraints. It doesn't fix knowledge gaps. Telling a model "generate OpenShift-native manifests" doesn't inject the knowledge it needs about your cluster's API versions or your cloud provider's annotation requirements. If the information isn't in the model's training data, no prompt will extract it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Run it locally."&lt;/strong&gt; I run Ollama on a 16GB Mac with models up to 14B parameters — Gemma 3 12B, DeepSeek-R1 14B, Qwen 2.5 Coder 14B. At that size, the models don't have meaningful OpenShift-specific knowledge. Loading a 9GB model on a machine with 16GB total — while running an IDE, terminal, and browser — means switching between models causes memory exhaustion. Local inference is a privacy-preserving fallback, not a primary working tier, for most practitioners on standard-issue hardware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Pick one provider and stay."&lt;/strong&gt; Until you can't. Free credits expire mid-demo. Rate limits hit during a customer presentation. Your compliance team flags a data residency concern. A premium reasoning model handles many of the same tasks a cheap utility model could — paying for capability you don't need on every request adds up fast. The question isn't whether you'll need to switch providers — it's whether your system survives the switch when it happens.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cn6cuesl3sm8rhjjb1j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cn6cuesl3sm8rhjjb1j.png" alt="LLM Provider Abstraction Control Plane on OpenShift" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The architecture has three layers, each preventing a different failure mode:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — Context Injection (Pre-processing).&lt;/strong&gt; Before any prompt reaches a model, platform-specific context is injected: your cluster's API versions, your cloud provider's annotation requirements, your organization's golden templates. This separates "what the model knows" from "what your environment requires" — and fills the gap with verified context, not model guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — LiteLLM Gateway (Routing).&lt;/strong&gt; A LiteLLM proxy running in the &lt;code&gt;ai-gateway&lt;/code&gt; namespace provides a single OpenAI-compatible API endpoint. Applications call the proxy, not individual providers. Model selection, failover, and tiering happen in the proxy configuration — not in application code. Three tiers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Characteristics&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Utility&lt;/td&gt;
&lt;td&gt;YAML generation, CRDs, JSON extraction, glue code&lt;/td&gt;
&lt;td&gt;Cheap, fast, deterministic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reasoning&lt;/td&gt;
&lt;td&gt;Security analysis, architecture decisions, multi-hop logic&lt;/td&gt;
&lt;td&gt;Slower, more expensive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escape hatch&lt;/td&gt;
&lt;td&gt;Edge cases, novel problems&lt;/td&gt;
&lt;td&gt;Manual or explicit opt-in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — Post-Generation Validation (Post-processing).&lt;/strong&gt; Every generated manifest runs through &lt;code&gt;oc apply --dry-run=server&lt;/code&gt; before being applied. The cluster's API server validates structure, API versions, and field correctness against the actual cluster state — not against documentation that may be stale.&lt;/p&gt;

&lt;p&gt;The key design insight: if a task is hard because of &lt;em&gt;logic&lt;/em&gt;, route to a stronger model. If a task is hard because of &lt;em&gt;domain knowledge&lt;/em&gt;, inject the missing context and keep it on a cheap model. Knowledge gaps are a context problem, not a model problem.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;New to evaluating AI tooling on OpenShift? &lt;a href="https://pipelineandprompts.com/posts/ai-tooling-openshift-evaluation-framework/" rel="noopener noreferrer"&gt;AI in the Stack #1 — AI Tooling on OpenShift: A Practitioner's Evaluation Framework&lt;/a&gt; covers the governance layer this gateway builds on.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works: Step by Step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;OpenShift 4.14+ cluster (ROSA, ARO, or self-managed)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;oc&lt;/code&gt; CLI 4.14+&lt;/li&gt;
&lt;li&gt;At least one LLM provider API key (OpenAI, Anthropic, or both)&lt;/li&gt;
&lt;li&gt;Namespace creation privileges on the cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1 — Configure Model Tiers
&lt;/h3&gt;

&lt;p&gt;The LiteLLM proxy configuration defines your model tiers and failover behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.yaml — LiteLLM proxy configuration&lt;/span&gt;
&lt;span class="na"&gt;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;utility&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpt-4.1-mini&lt;/span&gt;
      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/OPENAI_API_KEY&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;utility-fallback&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-haiku-4-5-20251001&lt;/span&gt;
      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/ANTHROPIC_API_KEY&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reasoning&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-sonnet-5&lt;/span&gt;
      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;os.environ/ANTHROPIC_API_KEY&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fallbacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;utility&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;utility-fallback&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;num_retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;request_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;

&lt;span class="na"&gt;router_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;routing_strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;simple-shuffle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fallbacks&lt;/code&gt; setting means if the utility model fails — rate limit, timeout, provider outage — LiteLLM automatically retries with the fallback. Your application code doesn't change. It still sends requests to &lt;code&gt;model: "utility"&lt;/code&gt; and the proxy handles the routing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Build the Context Layer
&lt;/h3&gt;

&lt;p&gt;Store platform-specific context in a ConfigMap. This is the knowledge your models lack — injected before every prompt, regardless of which provider handles the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# manifests/context-templates.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openshift-context-templates&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-gateway&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openshift-base.md&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;You are generating manifests for an OpenShift 4.14+ cluster.&lt;/span&gt;

    &lt;span class="s"&gt;Required API versions:&lt;/span&gt;
    &lt;span class="s"&gt;- Deployments: apps/v1&lt;/span&gt;
    &lt;span class="s"&gt;- Services: v1&lt;/span&gt;
    &lt;span class="s"&gt;- Routes: route.openshift.io/v1&lt;/span&gt;
    &lt;span class="s"&gt;- NetworkPolicies: networking.k8s.io/v1&lt;/span&gt;

    &lt;span class="s"&gt;Rules:&lt;/span&gt;
    &lt;span class="s"&gt;- Use OpenShift Routes, NOT Kubernetes Ingress&lt;/span&gt;
    &lt;span class="s"&gt;- Never use deprecated APIs (extensions/v1beta1, apps/v1beta1)&lt;/span&gt;
    &lt;span class="s"&gt;- Include resource requests and limits on all containers&lt;/span&gt;
    &lt;span class="s"&gt;- Use securityContext with runAsNonRoot: true&lt;/span&gt;

  &lt;span class="na"&gt;rosa-annotations.md&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;Target platform: ROSA (Red Hat OpenShift on AWS)&lt;/span&gt;

    &lt;span class="s"&gt;For Services of type LoadBalancer:&lt;/span&gt;
    &lt;span class="s"&gt;- service.beta.kubernetes.io/aws-load-balancer-type: "nlb"&lt;/span&gt;
    &lt;span class="s"&gt;- service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"&lt;/span&gt;

    &lt;span class="s"&gt;Do NOT use Azure-specific annotations on this cluster.&lt;/span&gt;

  &lt;span class="na"&gt;aro-annotations.md&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;Target platform: ARO (Azure Red Hat OpenShift)&lt;/span&gt;

    &lt;span class="s"&gt;For Services of type LoadBalancer:&lt;/span&gt;
    &lt;span class="s"&gt;- service.beta.kubernetes.io/azure-load-balancer-internal: "true"&lt;/span&gt;

    &lt;span class="s"&gt;Do NOT use AWS-specific annotations on this cluster.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application selects the appropriate context file based on the target cluster. On ROSA, prepend &lt;code&gt;openshift-base.md&lt;/code&gt; and &lt;code&gt;rosa-annotations.md&lt;/code&gt; to the system message. On ARO, swap in &lt;code&gt;aro-annotations.md&lt;/code&gt;. The model never has to guess which cloud it's targeting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Deploy the Gateway on OpenShift
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc new-project ai-gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the API key Secret — populate values from your external secret management system, never hardcode keys in manifests committed to Git:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# manifests/secret.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm-api-keys&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-gateway&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Opaque&lt;/span&gt;
&lt;span class="na"&gt;stringData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the proxy, service, and route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# manifests/deployment.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-gateway&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/berriai/litellm:main-latest&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
          &lt;span class="na"&gt;envFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm-api-keys&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app/config.yaml&lt;/span&gt;
              &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config.yaml&lt;/span&gt;
              &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--config"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/app/config.yaml"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--port"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;250m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;500m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
          &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
            &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
            &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
            &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
          &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;runAsNonRoot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
            &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config&lt;/span&gt;
          &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-config&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-gateway&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;route.openshift.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Route&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-gateway&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;termination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;edge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load the configuration and deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc create configmap litellm-config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;config.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; ai-gateway

oc apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/context-templates.yaml
oc apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/secret.yaml
oc apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the proxy is running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc get pods &lt;span class="nt"&gt;-n&lt;/span&gt; ai-gateway &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;litellm-proxy
oc get route litellm-proxy &lt;span class="nt"&gt;-n&lt;/span&gt; ai-gateway &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.host}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 — Wire Post-Generation Validation
&lt;/h3&gt;

&lt;p&gt;The minimum viable safety net — run this after every manifest generation, before &lt;code&gt;oc apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# scripts/validate-manifest.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;MANIFEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?Usage:&lt;span class="p"&gt; validate-manifest.sh &amp;lt;manifest.yaml&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Validating against cluster API server..."&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;oc apply &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;server &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MANIFEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PASS: Manifest accepted by &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;oc &lt;span class="nb"&gt;whoami&lt;/span&gt; &lt;span class="nt"&gt;--show-server&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: Manifest rejected — check API versions and resource types"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What &lt;code&gt;--dry-run=server&lt;/code&gt; catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API versions that don't exist on this cluster&lt;/li&gt;
&lt;li&gt;Resource types not installed (missing CRDs — Custom Resource Definitions — or operators)&lt;/li&gt;
&lt;li&gt;Required fields that are missing&lt;/li&gt;
&lt;li&gt;Field type mismatches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it does NOT catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrong cloud-provider annotations (annotations are unvalidated metadata)&lt;/li&gt;
&lt;li&gt;Runtime behavior (a valid Deployment can still crash-loop)&lt;/li&gt;
&lt;li&gt;Cross-resource dependency issues (Service referencing a non-existent Deployment)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For annotation validation on ROSA, add a targeted check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/check-cloud-annotations.sh&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"azure-load-balancer"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MANIFEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARN: Azure LB annotation found — target cluster is ROSA"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5 — Lock Down Access
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# manifests/rbac-and-network.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServiceAccount&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-gateway&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy-access&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-gateway&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm-proxy&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;ai-gateway-access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only namespaces with the &lt;code&gt;ai-gateway-access: "true"&lt;/code&gt; label can reach the proxy. Label the namespaces that need access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc label namespace my-ai-workloads ai-gateway-access&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Security and Operational Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;API keys belong in Secrets, not ConfigMaps.&lt;/strong&gt; The &lt;code&gt;llm-api-keys&lt;/code&gt; Secret is referenced via &lt;code&gt;envFrom&lt;/code&gt; — keys are injected as environment variables, never mounted as files and never committed to Git. In production, populate this Secret from your external secret management system (Vault, AWS Secrets Manager, Azure Key Vault) using an operator like External Secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operational IDs leak infrastructure details.&lt;/strong&gt; When troubleshooting with an LLM, you can scrub cluster names, VPC IDs, and IAM roles from the prompt context. You can't easily scrub operational IDs — error codes, correlation IDs, operation request IDs — because they're the thing you need the model to analyze. Those IDs can reveal internal infrastructure patterns, timing, and region information depending on the error format. For sensitive troubleshooting, route to local models and accept the latency cost. For everything else, the gateway's routing tier makes this a configuration choice, not a code change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure topology is the quieter data leak.&lt;/strong&gt; Even without credentials, the manifests you send to external providers reveal your namespace naming conventions, RBAC (role-based access control) structures, network policy architecture, and CRD layouts. The context templates compound this — they describe your exact cluster configuration. Ensure context injection happens in-cluster (before the request leaves your network) so the platform knowledge stays on your infrastructure, even when the LLM call goes external.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Route should use TLS termination appropriate to your environment.&lt;/strong&gt; The example uses &lt;code&gt;tls.termination: edge&lt;/code&gt;. For environments where the payload between the Route and the proxy must also be encrypted, use &lt;code&gt;reencrypt&lt;/code&gt; and configure a serving certificate on the proxy container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restrict egress from the gateway namespace.&lt;/strong&gt; The proxy needs outbound access to LLM provider APIs. It does not need access to your internal services. Add an egress NetworkPolicy that allows HTTPS to external endpoints and blocks everything else — this prevents the proxy from being used as an unintended hop into your internal network.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cloud-specific annotations are the silent failure.&lt;/strong&gt; In a multi-cluster deployment spanning ROSA and ARO, I've seen models confidently generate AWS Network Load Balancer annotations for manifests targeting an ARO cluster. The manifest passes &lt;code&gt;--dry-run=server&lt;/code&gt; — annotations are unvalidated metadata. The failure surfaces only at runtime when traffic doesn't route correctly. The context templates per cluster are the mitigation, but they require discipline: every cluster needs its own context maintained, and the mapping from target cluster to correct context file must be automated, not manual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cluster versions diverge.&lt;/strong&gt; Your staging cluster is on OpenShift 4.15. Production is still on 4.14 because the upgrade window hasn't opened. A manifest generated with 4.15 API assumptions may reference resources or fields that don't exist on 4.14. The &lt;code&gt;--dry-run=server&lt;/code&gt; validation catches this — but only if you run it against the &lt;em&gt;target&lt;/em&gt; cluster. Validating against staging and deploying to production defeats the purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Knowledge fragments across teams.&lt;/strong&gt; Five engineers, three different LLM providers, each with their own prompt patterns and context assumptions. The LiteLLM gateway standardizes the API layer. It doesn't standardize the context layer. Shared context templates in a ConfigMap help — but they require the same governance discipline as any other shared configuration artifact. Someone has to own the templates, review changes, and ensure they stay current when operators are upgraded or CRDs change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLMs smooth over differences instead of flagging them.&lt;/strong&gt; This is the hardest failure mode to catch. A model generating manifests for a mixed ROSA/ARO environment doesn't flag the differences between the platforms — it generates something generic that works on neither. The output looks correct because it's a plausible blend of both platforms. The only defense is platform-specific context injection and a practitioner who knows enough to recognize when the model is giving a plausible average instead of a specific answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Design the control plane before the first prompt&lt;/strong&gt; — context injection, gateway routing, and post-generation validation catch errors that trial-and-error model switching cannot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;oc apply --dry-run=server&lt;/code&gt; is your minimum viable safety net&lt;/strong&gt; — it collapses a multi-day debugging spiral into a five-minute feedback loop, at zero infrastructure cost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knowledge gaps are a context problem, not a model problem&lt;/strong&gt; — inject verified platform context into cheap models instead of paying for expensive models that still guess&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://pipelineandprompts.com/posts/n8n-workflows-platform-engineering-automation/" rel="noopener noreferrer"&gt;AI in the Stack #6 — Building n8n Workflows for Platform Engineering Automation&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article kept the LLM on a short leash — every call went through a gateway, every output through a validation gate before it touched the cluster. The next one hands an LLM agent a longer leash inside an n8n workflow, wired to an MCP server and a RAG pipeline for automated incident triage. The tradeoff: the moment an agent node can decide how many tool calls to make, the workflow stops being deterministic. That article covers the execution limits you need before you trust it to run unattended.&lt;/p&gt;

&lt;p&gt;Browse the rest of the &lt;a href="https://pipelineandprompts.com/series/ai-in-the-stack/" rel="noopener noreferrer"&gt;AI in the Stack series index&lt;/a&gt; for the full run.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Pipeline &amp;amp; Prompts | Byte size guides on DevOps, Cloud and AI&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openshift</category>
      <category>platformengineering</category>
      <category>litellm</category>
      <category>aiinthestack</category>
    </item>
  </channel>
</rss>
