<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Première publication]]></title><description><![CDATA[Première publication]]></description><link>https://cedric-poisson.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Première publication</title><link>https://cedric-poisson.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 01 Sep 2026 01:43:24 GMT</lastBuildDate><atom:link href="https://cedric-poisson.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[From Zero to a Working Active Directory Forest — Automating Everything with OpenTofu and Ansible]]></title><description><![CDATA[A few weeks ago I set myself a challenge: build a fully working Active Directory lab on Azure — forest, domain controller, organizational units, group policies, a domain-joined client — without touchi]]></description><link>https://cedric-poisson.hashnode.dev/from-zero-to-a-working-active-directory-forest-automating-everything-with-opentofu-and-ansible</link><guid isPermaLink="true">https://cedric-poisson.hashnode.dev/from-zero-to-a-working-active-directory-forest-automating-everything-with-opentofu-and-ansible</guid><category><![CDATA[Azure]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[ansible]]></category><category><![CDATA[Active Directory]]></category><category><![CDATA[Devops]]></category><category><![CDATA[azure-devops]]></category><dc:creator><![CDATA[Cédric Poisson]]></dc:creator><pubDate>Fri, 28 Aug 2026 09:29:18 GMT</pubDate><content:encoded><![CDATA[<p>A few weeks ago I set myself a challenge: build a fully working Active Directory lab on Azure — forest, domain controller, organizational units, group policies, a domain-joined client — without touching a single button in the Azure portal. Everything through code, everything reproducible, everything destroyable between sessions to keep the bill at zero.</p>
<p>This is the story of how <code>azure-ad-lab</code> came together, the bugs that fought back the hardest, and what I learned fixing them.</p>
<h2>The idea</h2>
<p>Two tools, two jobs:</p>
<ul>
<li><p><strong>OpenTofu</strong> answers "what needs to exist?" — the architect drawing the blueprint.</p>
</li>
<li><p><strong>Ansible</strong> answers "what does it need to do, once it exists?" — the contractor fitting it out.</p>
</li>
</ul>
<p>OpenTofu would provision the Azure side: a resource group, a VNet, a network security group locked down to my IP, two virtual machines (a Windows Server 2022 domain controller and a Windows 11 client), and a storage account to host a bootstrap script. Ansible would take over from there: turn the DC into an actual domain controller, carve out organizational units, attach group policies, and join the client to the domain.</p>
<p>Everything declarative, everything versioned on GitHub, everything torn down with <code>tofu destroy</code> at the end of each session.</p>
<h2>Getting comfortable with state</h2>
<p>The first real lesson had nothing to do with Active Directory — it was about Terraform's state file. The <code>.tfstate</code> is Terraform's memory of what it believes it has created. Lose it, and you lose the ability to know what's real. I stored it remotely in a separate resource group instead of leaving it local, mostly so a future CI/CD pipeline could share it safely and two <code>apply</code> runs couldn't collide.</p>
<p>That safety net still bit me once: an interrupted <code>apply</code> left a lock in place, and the next run failed with a blunt <code>state blob is already locked</code>. It wasn't a real concurrent operation — just a stale lock from a run that never finished cleanly. <code>tofu force-unlock</code> cleared it. Small scare, useful reminder that infrastructure-as-code has its own failure modes that have nothing to do with the infrastructure itself.</p>
<h2>The WinRM bootstrap, and its two bugs</h2>
<p>Ansible needs a way to reach into a Windows VM the way it reaches into a Linux one over SSH. That way is WinRM, and Azure doesn't turn it on for external access by default.</p>
<p>The first surprise: unlike Linux's cloud-init, Windows doesn't run <code>custom_data</code> automatically. You need a dedicated <code>CustomScriptExtension</code> resource to execute a bootstrap script on first boot.</p>
<p>That script itself hid two bugs that taught me more than the rest of the project combined.</p>
<p><strong>Bug one</strong> was Azure's own idempotence check working against me. I'd update the bootstrap script, redeploy, and Azure would decide nothing had changed — because the <em>URL</em> pointing to the script hadn't changed, even though its contents had. Azure was checking the wrong thing. The fix was to feed it the right thing: inject the script's <code>filemd5()</code> hash into the extension's settings, so any content change produces a different hash and therefore a visible difference. It's a pattern worth remembering beyond this one script: when a system won't detect a change in content, give it a piece of metadata that's derived from that content instead.</p>
<p><strong>Bug two</strong> was quieter and nastier. A raw <code>winrm create ...</code> command was failing on nested quoting — and failing <em>silently</em>. The extension reported <code>Succeeded</code> regardless. I only caught it because WinRM connections kept refusing afterward. Swapping the shell command for the native <code>New-Item -Path WSMan:\LocalHost\Listener</code> cmdlet fixed it properly, and honestly, reliably, instead of pretending to.</p>
<h2>Learning the shape of Active Directory</h2>
<p>Before this project, AD's hierarchy was something I already could recite and build. Forest, domain, domain controller, organizational units, objects — nested containers all the way down, each one a place to hang policy.</p>
<p>One detail cost me a failed domain join before I understood it: I'd set a custom admin username on the VM instead of leaving it as <code>Administrator</code>. That custom account becomes the domain admin the moment the DC is promoted — it isn't renamed, it just <em>is</em> the admin now. My first join attempt failed authentication because I was still looking for a default account that was never going to exist on this domain.</p>
<h2>Ansible: idempotence isn't automatic</h2>
<p>Ansible modules are supposed to be idempotent — safe to run twice, changing nothing the second time if nothing needs to change. Most of them are. <code>New-GPLink</code> was not: running it again against a link that already existed made it fail outright instead of quietly confirming the link was fine.</p>
<p>The fix wasn't glamorous — a manual check with <code>Get-GPInheritance</code> before attempting the link — but it's a good reminder that "idempotent" is a design goal for a module author, not a guarantee you get for free. Sometimes you have to build the idempotence yourself, one <code>if</code> at a time.</p>
<p>The roles ended up running in a strict sequence: prep the DC's Windows features, create the forest and promote it, carve out OUs and attach GPOs, then join the client. Each step depends on the one before it, which is exactly why the order in <code>site.yml</code> matters — Ansible runs top to bottom, no shortcuts.</p>
<h2>Keeping the secrets out of Git</h2>
<p>DSRM passwords and domain-join credentials can't live in plaintext in a versioned repo. Ansible Vault encrypts them with AES-256 behind a password only I know, and I structured <code>group_vars</code> as folders rather than single files — a plaintext <code>vars.yml</code> next to an encrypted <code>vault.yml</code> in the same group, both loaded automatically. A vaulted file is safe to commit. A plaintext <code>.tfvars</code> never is, and it stays out of the repo entirely via <code>.gitignore</code>.</p>
<h2>The whole path, start to finish</h2>
<p>By the end, one command kicks off a sequence that used to be a checklist of manual clicks:</p>
<pre><code class="language-plaintext">tofu apply
  → Azure creates the VNet, NSG, VMs, storage account

CustomScriptExtension runs winrm_bootstrap.ps1
  → WinRM/HTTPS is live on every VM

ansible win_ping
  → confirms connectivity

ad_dc_prep → installs AD-Domain-Services and DNS
ad_domain_create → creates the lab.local forest, promotes the DC, handles the reboot
ad_ou_gpo → creates the OUs and GPOs, links them
client_join → the client resolves lab.local and joins the domain
</code></pre>
<p>Fifteen minutes later, <code>tofu destroy</code> puts it all away again, and the next session starts from exactly the same clean slate.</p>
<h2>What's left</h2>
<p>The functional level of the domain is still sitting at the default <code>Windows2016Domain</code> — never explicitly set, harmless for a lab, but something I'd fix if this were ever going to be more than a lab. GPO creation still leans on raw PowerShell rather than a mature native module, which works but isn't as clean as the OU management, which does have one.</p>
<p>Small imperfections aside, the thing that stuck with me most is how much of "automating infrastructure" turns out to be automating <em>trust</em> — trusting a hash to reveal a real change, trusting a manual check to make an unreliable module behave, trusting a vault to keep a secret out of a place it should never be. The code was the easy part. Knowing what to distrust was the project.</p>
<p>The full source is on GitHub: <a href="https://github.com/cedric-poisson/azure-ad-lab">azure-ad-lab</a>.</p>
<hr />
<p><em>A note on process: I built this project with Claude as a technical guide. I still ran every command, hit and debugged every failure myself, and made the architecture decisions — Ansible concepts here were new to me, and having something to ask "why is this failing" made the learning curve a lot less brutal. Still learning, still building.</em></p>
]]></content:encoded></item><item><title><![CDATA[Every Error I Hit Deploying Kubernetes on Azure With OpenTofu (and What Each One Taught Me)]]></title><description><![CDATA[I spent a weekend turning an empty GitHub repo into a Kubernetes cluster running on Azure, provisioned entirely through code, deployed through a CI/CD pipeline that triggers on merge. That sentence to]]></description><link>https://cedric-poisson.hashnode.dev/every-error-i-hit-deploying-kubernetes-on-azure-with-opentofu-and-what-each-one-taught-me</link><guid isPermaLink="true">https://cedric-poisson.hashnode.dev/every-error-i-hit-deploying-kubernetes-on-azure-with-opentofu-and-what-each-one-taught-me</guid><category><![CDATA[Azure]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[azure-devops]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[#IaC]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Cédric Poisson]]></dc:creator><pubDate>Tue, 25 Aug 2026 13:36:58 GMT</pubDate><content:encoded><![CDATA[<p>I spent a weekend turning an empty GitHub repo into a Kubernetes cluster running on Azure, provisioned entirely through code, deployed through a CI/CD pipeline that triggers on merge. That sentence took about six lines to write and roughly a dozen distinct failures to actually build. This post is not a "here's how you deploy AKS with OpenTofu in 10 minutes" tutorial — those already exist, and they lie to you by omission. This is the version with all the parts left in: the exact error messages, why each one happened, and what it taught me about how Azure, Kubernetes, and Infrastructure as Code actually behave once you leave the happy path.</p>
<p>A quick note on process, because I think it matters: I built this project working alongside Claude (Anthropic's AI assistant) as a learning copilot — not as an autocomplete that wrote code for me to paste blindly, but as something closer to a very patient senior engineer sitting next to me, explaining <em>why</em> before I ran anything, and helping me read error messages instead of just handing me the fix. Every command in this post, I understand. Every incident below, I diagnosed or at least reasoned through before applying a fix. I'm mentioning this upfront because I think "how you use AI tools" is becoming its own skill worth being honest about, not something to hide.</p>
<h2>What I actually built</h2>
<p>The end state: a resource group and VNet provisioned by <a href="https://opentofu.org/">OpenTofu</a> (the open-source Terraform fork) against Azure's <code>azurerm</code> provider, a test VM, an AKS cluster with a single node pool, an nginx deployment on that cluster via a custom Helm chart, and a GitHub Actions pipeline that runs <code>tofu plan</code> on every pull request and <code>tofu apply</code> automatically on merge to <code>main</code>, backed by remote state stored in Azure Blob Storage.</p>
<p>None of that came together in a straight line. Here's the actual path.</p>
<h2>The setup, briefly</h2>
<p>If you want the reproducible skeleton before the war stories: WSL2 + Ubuntu + VS Code with the Remote-WSL extension for the dev environment, a GitHub repo with a <code>.gitignore</code> covering <code>*.tfstate</code>, <code>.terraform/</code>, and <code>*.tfvars</code> from day one (Azure credentials should never land in git history, even briefly), and a disciplined feature-branch → pull request → merge workflow for every single change, including one-line documentation fixes. That last part felt like overkill for a solo project at first. It stopped feeling like overkill the moment Incident 9 happened.</p>
<p>Authentication for OpenTofu itself goes through a dedicated Azure Service Principal — not your own user account — created once with:</p>
<pre><code class="language-bash">az ad sp create-for-rbac --name "sp-homelab-infra" --role="Contributor" \
  --scopes="/subscriptions/&lt;subscription-id&gt;"
</code></pre>
<p>which hands back a client ID, secret, and tenant ID. Those get exported as <code>ARM_CLIENT_ID</code>, <code>ARM_CLIENT_SECRET</code>, <code>ARM_TENANT_ID</code>, and <code>ARM_SUBSCRIPTION_ID</code> — environment variables the <code>azurerm</code> provider reads automatically, so no credential ever appears in a <code>.tf</code> file:</p>
<pre><code class="language-hcl">provider "azurerm" {
  features {}
}
</code></pre>
<p>From there, every resource is a straightforward <code>resource</code> block — a resource group, a VNet with subnets, a VM, an AKS cluster — chained together through implicit references (<code>azurerm_resource_group.homelab.location</code>, and so on) so OpenTofu can work out the correct creation order on its own. The mechanics are genuinely simple. Everything below is what happens once that simple code meets a real cloud account.</p>
<h2>Incident 1: The VM size that doesn't exist where I need it</h2>
<p>The very first <code>tofu apply</code> — a single VM, nothing fancy — failed with:</p>
<pre><code class="language-plaintext">Error: creating Linux Virtual Machine: unexpected status 400 (400 Bad Request)
SkuNotAvailable: The requested VM size for resource 'Standard_B1s' is currently
not available in location 'FranceCentral'.
</code></pre>
<p>This wasn't a code bug. It was a capacity constraint: Azure reserves finite hardware capacity per region per SKU, and a student subscription can get shut out of specific VM families in a given region even when the SKU exists globally. The fix was mundane — switch to a region with available capacity (<code>swedencentral</code> worked) — but the lesson wasn't. Cloud "infinite capacity" is a product promise, not a physical fact. Your first deploy in a new region is also implicitly a capacity availability check, and you won't know the answer until you try.</p>
<h2>Incident 2: The IP quota that was actually someone else's fault (mine, six months ago)</h2>
<p>Setting up a test VM with a public IP threw:</p>
<pre><code class="language-plaintext">PublicIPCountLimitReached: Cannot create more than 3 public IP addresses
for this subscription in this region.
</code></pre>
<p>Three was already used. By what? Not by this project — by three VMs left running from an old school assignment I'd completely forgotten about, sitting in a resource group I hadn't touched in months. I found them with <code>az network public-ip list</code>, disassociated the IPs from their NICs, deleted them, and moved on.</p>
<p>The real lesson showed up later, when I checked my Azure credit balance: <strong>$17 of it had gone to storage costs from those same forgotten VMs — despite the VMs being stopped.</strong> Stopping (or deallocating) a VM halts compute billing. It does <em>not</em> stop billing for the managed disk attached to it, because the disk still physically exists in storage regardless of whether the VM is powered on. The only thing that stops disk billing is deleting the disk. "I turned it off" and "I stopped paying for it" are not the same sentence in cloud billing, and that gap is exactly where forgotten test resources quietly drain a budget.</p>
<h2>Incident 3: Azure lied to me for about four seconds</h2>
<p>Recreating my VNet after a region migration, I got:</p>
<pre><code class="language-plaintext">Error: Provider produced inconsistent result after apply
"provider ... produced an unexpected new value: root object was present, but now absent."
</code></pre>
<p>followed on retry by:</p>
<pre><code class="language-plaintext">Error: A resource with the ID ".../virtualNetworks/vnet-homelab-dev" already exists
</code></pre>
<p>Contradictory on the surface — first it doesn't exist, then it already exists — but consistent once you know what's happening. OpenTofu creates a resource, then immediately reads it back to confirm and populate its state. Azure Resource Manager is a distributed system with multiple backend replicas, and that immediate read-back can hit a replica that hasn't caught up yet, returning a false 404 for a resource that was, in fact, created successfully a moment earlier. I confirmed this wasn't specific to my setup by checking the <code>terraform-provider-azurerm</code> GitHub issues — this exact failure mode has multiple open reports across different resource types, not just VNets.</p>
<p>The fix isn't retrying <code>apply</code> and hoping — it's <code>tofu import</code>, which tells OpenTofu "this resource already exists for real, attach it to your state instead of trying to create it again." Useful thing to know: sometimes the tool's confusion is a timing problem in the API underneath it, not a bug in your configuration, and the fix is to reconcile state with reality rather than fight the tool.</p>
<h2>Incident 4: AKS rejected me three times before accepting a node</h2>
<p>Getting an AKS cluster's node pool provisioned took three separate rejections, each teaching something different about how managed Kubernetes actually allocates compute:</p>
<p><strong>Rejection one — too small:</strong></p>
<pre><code class="language-plaintext">SystemPoolSkuTooLow: System node pool must use VM sku with more than
2 cores and 4GB memory.
</code></pre>
<p>The VM size I'd used successfully for a plain VM (<code>Standard_B2ats_v2</code>, an ARM-based burstable size with only 1GB RAM) wasn't enough to run AKS's system pods (CoreDNS, metrics-server, etc.). A managed Kubernetes control plane still needs a real floor of worker resources — "managed" removes the ops burden, not the resource requirements.</p>
<p><strong>Rejection two — not entitled:</strong></p>
<pre><code class="language-plaintext">The VM size of Standard_B2s is not allowed in your subscription in
location 'swedencentral'. The available VM sizes are [...]
</code></pre>
<p>A completely different axis of restriction: AKS maintains its own allowlist of VM SKUs per subscription/region, separate from general compute availability.</p>
<p><strong>Rejection three — no room left:</strong></p>
<pre><code class="language-plaintext">ErrCode_InsufficientVCPUQuota: Insufficient regional vcpu quota left for
location swedencentral. left regional vcpu quota 0, requested quota 2.
</code></pre>
<p>This is where I learned that Azure enforces a <strong>total regional vCPU quota shared across every VM family combined</strong>, separate from the per-family quotas. My leftover test VM from Incident 2's cleanup was silently eating the exact headroom the AKS node needed. <code>az vm list-usage --location &lt;region&gt; --output table</code> is the command that turns this from a guessing game into a fact — it lists the real "Total Regional vCPUs" limit and current usage, and would have saved me two rounds of blind retries if I'd run it first instead of last.</p>
<h2>Incident 5: Two networks in the same house</h2>
<p>AKS creation then failed with:</p>
<pre><code class="language-plaintext">ServiceCidrOverlapExistingSubnetsCidr: The specified service CIDR
10.0.0.0/16 is conflicted with an existing subnet CIDR 10.0.1.0/24.
</code></pre>
<p>Kubernetes maintains its own virtual address space for internal Services — a purely logical range, never routed on the real network, used to give Services stable internal IPs. AKS defaults this to <code>10.0.0.0/16</code> unless told otherwise, which collided head-on with my VNet using the same range. The fix was an explicit <code>network_profile</code> block specifying a disjoint <code>service_cidr</code> (<code>10.100.0.0/16</code>). The broader point: a Kubernetes cluster running inside a cloud VNet is actually managing <em>two</em> overlapping-in-concept-but-separate-in-practice networks, and nothing forces you to notice until they collide.</p>
<h2>Incident 6: The IP that wasn't the IP</h2>
<p>After finally deploying nginx via Helm with a <code>LoadBalancer</code> service, I ran <code>curl</code> against what I thought was the public address and got connection refused. I'd grabbed the <code>CLUSTER-IP</code> column instead of <code>EXTERNAL-IP</code> — an internal-only address from that same Service CIDR from Incident 5, not the real public IP Azure had provisioned. <code>LoadBalancer</code> as a Service type is actually a two-layer mechanism: it builds on <code>NodePort</code> internally, and additionally asks the cloud provider's integration to provision a real external load balancer with a public IP. Two IPs, two very different audiences, one easy mix-up the first time you see them side by side.</p>
<h2>Incident 7: The pipeline that couldn't remember anything</h2>
<p>Before wiring up GitHub Actions, my Terraform state lived only as a local file on my machine, deliberately <code>.gitignore</code>d. That's fine for solo local work and catastrophic for CI: a GitHub Actions runner is a fresh, disposable machine on every single run, with zero memory of any previous run. Point a pipeline at that setup and it would either try to recreate everything from scratch every time, or silently diverge from whatever I'd applied locally.</p>
<p>The fix is a <strong>remote backend</strong> — in this case, an Azure Storage Account holding the state as a blob, readable and writable from both my laptop and the CI runner, so both always agree on what actually exists. Worth noting: the backend config block itself can't reference variables or other resources — it has to be hardcoded — and the storage account it points to has to be provisioned <em>before</em> OpenTofu can use it to manage anything else, including, technically, itself.</p>
<h2>Incident 8: A file that only existed on my laptop</h2>
<p>With the backend fixed, the pipeline's first real <code>apply</code> failed on:</p>
<pre><code class="language-plaintext">Error: Invalid function argument
Invalid value for "path" parameter: no file exists at "~/.ssh/homelab_vm_rsa.pub"
</code></pre>
<p>My VM resource read its SSH public key from a path in my home directory — which, again, exists only on my machine, not on GitHub's ephemeral runner. The fix doubles as a useful reminder about the public/private key distinction: a <strong>public</strong> key is meant to be shared, so committing it into the repo and referencing it with <code>${path.module}/...</code> (a path relative to the Terraform module, portable across any machine that clones the repo) is completely safe. The private key never left my laptop and never will.</p>
<h2>Incident 9: A safety net with a hole in it</h2>
<p>Somewhere in this process, a pull request with a genuinely broken pipeline — the <code>plan</code> check had failed — got merged into <code>main</code> anyway, because I hadn't set up branch protection. Nothing technical stopped it; only habit had been stopping it, and habit isn't a control. I added a classic branch protection rule requiring the <code>plan</code> status check to pass before merge is even allowed. As a solo maintainer, I skipped the "require approving review" option — it defaults to requiring at least one approval from someone else, which quietly locks a single-person repo out of merging its own work.</p>
<h2>Incident 10: The setting that couldn't be un-set</h2>
<p>The most interesting bug came last, triggered by a documentation-only commit that shouldn't have touched infrastructure at all. The pipeline's <code>apply</code> job ran anyway (my workflow triggers on any push to <code>main</code>, not just <code>.tf</code> changes — a known inefficiency I haven't fixed yet) and failed with:</p>
<pre><code class="language-plaintext">OIDCIssuerFeatureCannotBeDisabled: OIDC issuer feature cannot be disabled.
</code></pre>
<p>My AKS resource block never set <code>oidc_issuer_enabled</code> explicitly, so OpenTofu treated the provider's default (<code>false</code>) as the desired state. Azure, independently, had actually provisioned the cluster with that feature <em>on</em> by default. Two different systems, two different defaults, silently disagreeing — and Azure's API refuses the "downgrade" outright, because OIDC issuer is a one-way switch once enabled. The fix was one line — declaring <code>oidc_issuer_enabled = true</code> explicitly, matching reality instead of relying on an implicit default. The general lesson generalizes well beyond this one flag: <strong>anything your code doesn't declare explicitly is a place where your tool's assumption and the provider's actual behavior can quietly disagree</strong>, and that disagreement only surfaces the next time something touches that resource.</p>
<h2>What all of this actually taught me</h2>
<p>None of these were "I don't understand OpenTofu" problems. They were mostly Azure being a distributed system with real quotas, real regional variance, and real API eventual-consistency, wrapped by a provider that does its honest best to model something more chaotic underneath. Reading the actual error text carefully — not skimming it — was the single highest-leverage habit through all of this. Every one of the ten incidents above was fully explained by its own error message, once I stopped pattern-matching to "something's broken" and started reading what it specifically said.</p>
<p>The AI-copilot angle deserves one more honest sentence: Claude didn't know in advance that <code>Standard_B2ats_v2</code> would be capacity-restricted in <code>francecentral</code>, or that my subscription's regional vCPU quota was sitting at zero. Nobody could know that without hitting Azure's actual API and reading its actual response. What the collaboration did well was turning "here's a cryptic error" into "here's what this specific error means, here's how to verify the actual cause instead of guessing, here's the fix and why it works" — fast enough that a full day of real, unglamorous cloud debugging turned into a full day of <em>learning</em>, instead of a full day of quietly giving up.</p>
<h2>What's next</h2>
<p>This project isn't done as a portfolio piece, just as a first pass. Next on the list: scanning the OpenTofu code itself for security misconfigurations (<code>tfsec</code>/<code>checkov</code>) as an added CI step, standing up Prometheus and Grafana on the cluster for actual observability, and eventually migrating deployment to a GitOps model with ArgoCD or FluxCD instead of a pipeline that pushes changes directly. If you're building something similar and hit one of these exact error strings on Google — hopefully this saved you the four hours it took me.</p>
]]></content:encoded></item></channel></rss>