<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://churi12.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://churi12.github.io/" rel="alternate" type="text/html" /><updated>2026-07-29T10:55:23+00:00</updated><id>https://churi12.github.io/feed.xml</id><title type="html">Miguel Santos</title><subtitle>Infrastructure and platform engineering. Kubernetes, AWS, ArgoCD, Karpenter, and the Grafana stack, and the things I learn fixing them.</subtitle><author><name>Miguel Santos</name></author><entry><title type="html">The log level that only looked configurable</title><link href="https://churi12.github.io/2026/07/the-log-level-that-only-looked-configurable/" rel="alternate" type="text/html" title="The log level that only looked configurable" /><published>2026-07-29T00:00:00+00:00</published><updated>2026-07-29T00:00:00+00:00</updated><id>https://churi12.github.io/2026/07/the-log-level-that-only-looked-configurable</id><content type="html" xml:base="https://churi12.github.io/2026/07/the-log-level-that-only-looked-configurable/"><![CDATA[<p>The Stackable Airflow operator generates Airflow’s logging config from your CRD. You set a level, it renders the Python. An open issue asked for one specific thing: make the log level of the <code class="language-plaintext highlighter-rouge">task</code> handler configurable. There was even a comment in the source saying it needed doing.</p>

<p>I started by trying to prove the comment was wrong. It looked wrong. The CRD has a <code class="language-plaintext highlighter-rouge">loggers</code> map, the docs show an <code class="language-plaintext highlighter-rouge">airflow.task</code> entry in it, and setting that entry does change what you see in the Airflow UI. So the feature seemed to already exist and nobody had noticed.</p>

<h2 id="handlers-filter-loggers-gate">Handlers filter, loggers gate</h2>

<p>It does work, but not as the same thing. Python’s <code class="language-plaintext highlighter-rouge">logging</code> has two thresholds on the path from a log call to an output, and they are not interchangeable.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="p">.</span><span class="nf">getLogger</span><span class="p">(</span><span class="sh">'</span><span class="s">airflow.task</span><span class="sh">'</span><span class="p">)</span>   <span class="c1"># threshold 1: the logger
</span><span class="n">handler</span> <span class="o">=</span> <span class="n">logger</span><span class="p">.</span><span class="n">handlers</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>                 <span class="c1"># threshold 2: the handler
</span></code></pre></div></div>

<p>A record is dropped at the logger before any handler is consulted. If it survives, each handler applies its own level independently. So the logger is a gate on the whole path, and a handler level filters one destination.</p>

<p>That distinction is the entire issue. Airflow’s <code class="language-plaintext highlighter-rouge">task</code> handler is what feeds the log view in the web UI. The other handlers write to files, which is what the Vector agent ships off the node. If you raise the <code class="language-plaintext highlighter-rouge">airflow.task</code> logger to suppress noise in the UI, you have also stopped those records from ever reaching the files. The thing people actually wanted, from a second issue on the same topic, was DEBUG in the files and in the log aggregator while the UI stays readable at INFO. A logger level cannot express that. Only a handler level can.</p>

<p>So the comment in the source was right and my read of it was wrong. Good thing to establish before writing a line of code.</p>

<h2 id="building-the-obvious-thing">Building the obvious thing</h2>

<p>The CRD shape looked like the blocker. Levels come from a struct with named fields:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">AutomaticContainerLogConfig</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">loggers</span><span class="p">:</span> <span class="n">BTreeMap</span><span class="o">&lt;</span><span class="nb">String</span><span class="p">,</span> <span class="n">LoggerConfig</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">console</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">AppenderConfig</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">file</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">AppenderConfig</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">console</code> and <code class="language-plaintext highlighter-rouge">file</code> are fields. <code class="language-plaintext highlighter-rouge">loggers</code> is a map, so any key you invent works without changing the struct, which is exactly why the <code class="language-plaintext highlighter-rouge">airflow.task</code> workaround needed nothing upstream. But a <code class="language-plaintext highlighter-rouge">task</code> field next to <code class="language-plaintext highlighter-rouge">console</code> and <code class="language-plaintext highlighter-rouge">file</code> is a struct change, and that struct lives in a shared crate. I checked: thirteen operators consume it.</p>

<p>I built it anyway. Added the field, added it to the hand-written merge implementation, updated twenty-five struct literals the compiler pointed me at, regenerated the CRDs, wrote the tests, opened the upstream PR, and wired the operator side to it. It worked. I verified it end to end by rendering the generated Python and running it through <code class="language-plaintext highlighter-rouge">dictConfig</code>, because a test asserting on generated source does not prove the level takes effect at runtime.</p>

<h2 id="the-reply-that-made-it-smaller">The reply that made it smaller</h2>

<p>A maintainer answered roughly: I would rather not add a field to every product’s CRD when only Airflow uses it. Then he offered an alternative, and noted that reading the docs he would have expected <code class="language-plaintext highlighter-rouge">airflow.task</code> in <code class="language-plaintext highlighter-rouge">loggers</code> to already be exactly that knob.</p>

<p>Which is the point I had walked straight past. If the level has to come from somewhere, and <code class="language-plaintext highlighter-rouge">loggers</code> already accepts any key, and the docs already tell people to put <code class="language-plaintext highlighter-rouge">airflow.task</code> there, then the value is already in the CRD. Nothing needs adding. What needed changing was what the operator does with it: send it to the handler instead of to the logger.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">task_log_level</span><span class="p">(</span><span class="n">log_config</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">AutomaticContainerLogConfig</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="n">LogLevel</span> <span class="p">{</span>
    <span class="n">log_config</span>
        <span class="py">.loggers</span>
        <span class="nf">.get</span><span class="p">(</span><span class="n">TASK_LOGGER</span><span class="p">)</span>
        <span class="nf">.map</span><span class="p">(|</span><span class="n">logger</span><span class="p">|</span> <span class="n">logger</span><span class="py">.level</span><span class="p">)</span>
        <span class="nf">.unwrap_or</span><span class="p">(</span><span class="nn">LogLevel</span><span class="p">::</span><span class="n">INFO</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">fn</span> <span class="nf">task_logger_level</span><span class="p">(</span><span class="n">log_config</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">AutomaticContainerLogConfig</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="n">LogLevel</span> <span class="p">{</span>
    <span class="nn">cmp</span><span class="p">::</span><span class="nf">min</span><span class="p">(</span><span class="nn">LogLevel</span><span class="p">::</span><span class="n">INFO</span><span class="p">,</span> <span class="nf">task_log_level</span><span class="p">(</span><span class="n">log_config</span><span class="p">))</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The second function is the part worth explaining. The handler gets whatever you asked for. The logger gets the lower of INFO and your level, never the higher. Asking for DEBUG has to open the logger up too, or it would drop the records before the handler could pass them. Asking for ERROR must not raise the logger, because the console and file handlers still want everything from INFO up. Clamping in one direction is what lets one knob mean the right thing in both.</p>

<p>He also offered a second option: just reuse the <code class="language-plaintext highlighter-rouge">file</code> level for the task handler, as a close-enough approximation. I pushed back on that one. It collapses the exact distinction the issue is about, and it would silently start showing DEBUG in the UI to anyone who has <code class="language-plaintext highlighter-rouge">file</code> set to DEBUG today. Agreeing with the reviewer on the good idea does not mean agreeing with all of them.</p>

<h2 id="what-the-rewrite-cost">What the rewrite cost</h2>

<p>The CRD version touched two repositories, needed a release of the shared crate before it could merge, changed a struct thirteen operators depend on, and added 88 keys to the generated CRD schema. The rewrite touches three files in one repository and changes no schema at all. Same feature.</p>

<p>There is one honest wart, and I put it in the PR rather than in a footnote. This reinterprets a key that already does something. Today, raising <code class="language-plaintext highlighter-rouge">airflow.task</code> to ERROR suppresses records everywhere; afterwards it only quietens the UI. That is the improvement, and it is also a behaviour change, and someone relying on the old side effect to cut log volume would notice.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>I spent most of the effort on the version that got thrown away, and the reason is not that the design was wrong. It is that I accepted the framing in my own head, that a handler level needs a handler field, and never asked whether the value could come from somewhere that already existed. The maintainer did not know Airflow’s logging internals better than I did by then. He just had not adopted my constraint.</p>

<p>Worth noticing too that he arrived at the answer by reading the documentation and saying what he expected to be true. When a reviewer tells you what they assumed the feature already did, that is not confusion to correct. It is a fairly direct hint about where the feature belongs. The change is in <a href="https://github.com/stackabletech/airflow-operator/pull/829">stackabletech/airflow-operator#829</a>.</p>]]></content><author><name>Miguel Santos</name></author><category term="airflow" /><summary type="html"><![CDATA[The Stackable Airflow operator generates Airflow’s logging config from your CRD. You set a level, it renders the Python. An open issue asked for one specific thing: make the log level of the task handler configurable. There was even a comment in the source saying it needed doing.]]></summary></entry><entry><title type="html">Argo CD resolved my Helm chart as a git repo</title><link href="https://churi12.github.io/2026/07/argo-cd-resolved-my-helm-chart-as-a-git-repo/" rel="alternate" type="text/html" title="Argo CD resolved my Helm chart as a git repo" /><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://churi12.github.io/2026/07/argo-cd-resolved-my-helm-chart-as-a-git-repo</id><content type="html" xml:base="https://churi12.github.io/2026/07/argo-cd-resolved-my-helm-chart-as-a-git-repo/"><![CDATA[<p>Someone reported an Argo CD regression that only shows up in a fairly specific shape of Application: a multi-source app that combines an untyped Helm chart source with a git values source and the <code class="language-plaintext highlighter-rouge">argocd.argoproj.io/manifest-generate-paths</code> annotation. On v3.4.3 it started failing with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>unable to resolve git revision : failed to list refs: repository not found
</code></pre></div></div>

<p>The chart repository is not a git repo, so of course listing git refs against it fails. The real question is why Argo CD was trying to list git refs against a Helm chart URL at all. It had all the information it needed to know this was a Helm source.</p>

<h2 id="the-guard-that-used-to-short-circuit">The guard that used to short-circuit</h2>

<p><code class="language-plaintext highlighter-rouge">UpdateRevisionForPaths</code> in the reposerver decides, per source, whether it needs to resolve a git revision for the manifest-generate-paths optimization. A non-git source with no ref sources has nothing to resolve, so the function returns early:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">repo</span><span class="o">.</span><span class="n">Type</span> <span class="o">!=</span> <span class="s">"git"</span> <span class="o">&amp;&amp;</span> <span class="nb">len</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">RefSources</span><span class="p">)</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
    <span class="k">return</span> <span class="o">&amp;</span><span class="n">apiclient</span><span class="o">.</span><span class="n">UpdateRevisionForPathsResponse</span><span class="p">{},</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And the git work itself is gated the same way:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">repo</span><span class="o">.</span><span class="n">Type</span> <span class="o">==</span> <span class="s">"git"</span> <span class="p">{</span>
    <span class="c">// resolve the git revision...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For an untyped Helm chart source with ref sources, this is exactly the seam where things go wrong.</p>

<h2 id="normalize-turned-unset-into-git">Normalize turned “unset” into “git”</h2>

<p>The regression came in with one added line. Someone put a <code class="language-plaintext highlighter-rouge">repo.Normalize()</code> call at the top of the function to tidy up the request:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">repo</span> <span class="o">=</span> <span class="n">repo</span><span class="o">.</span><span class="n">Normalize</span><span class="p">()</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Normalize</code> is helpful in most places, but it does one thing that matters here: when the repository type is unset, it coerces it to the default, which is <code class="language-plaintext highlighter-rouge">git</code>. An untyped Helm chart source (no explicit type, chart repo not registered as a repository secret) has an empty type. After <code class="language-plaintext highlighter-rouge">Normalize</code>, that empty type is <code class="language-plaintext highlighter-rouge">git</code>.</p>

<p>Now walk the two guards again. The source has ref sources, so the early return does not fire. Its type is now <code class="language-plaintext highlighter-rouge">git</code>, so it enters the git branch and tries to resolve a git revision against the Helm chart URL. <code class="language-plaintext highlighter-rouge">repository not found</code>. Before <code class="language-plaintext highlighter-rouge">Normalize</code> was added, the empty type was not <code class="language-plaintext highlighter-rouge">git</code>, the early return fired, and the function did the right thing by doing nothing.</p>

<p>The bug is not in <code class="language-plaintext highlighter-rouge">Normalize</code>. It is in treating “unset” as “definitely git” at a decision point where unset could just as easily mean Helm or OCI.</p>

<h2 id="the-fix-decide-git-ness-before-normalizing">The fix: decide git-ness before normalizing</h2>

<p>Instead of letting <code class="language-plaintext highlighter-rouge">Normalize</code> guess, I capture whether the source is really git before it runs, and fall back to the application source type when the repository type is genuinely unset:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">isGitSource</span> <span class="o">:=</span> <span class="n">repo</span><span class="o">.</span><span class="n">Type</span> <span class="o">==</span> <span class="s">"git"</span>
<span class="k">if</span> <span class="n">repo</span><span class="o">.</span><span class="n">Type</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
    <span class="n">isGitSource</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">ApplicationSource</span> <span class="o">==</span> <span class="no">nil</span> <span class="o">||</span>
        <span class="p">(</span><span class="o">!</span><span class="n">request</span><span class="o">.</span><span class="n">ApplicationSource</span><span class="o">.</span><span class="n">IsHelm</span><span class="p">()</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">request</span><span class="o">.</span><span class="n">ApplicationSource</span><span class="o">.</span><span class="n">IsOCI</span><span class="p">())</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then both guards key off <code class="language-plaintext highlighter-rouge">isGitSource</code> instead of <code class="language-plaintext highlighter-rouge">repo.Type</code>. An untyped source is only treated as git when the application source is not Helm and not OCI. A genuine git repo that happens to use Helm value overrides (a git source with <code class="language-plaintext highlighter-rouge">Helm:</code> set but no <code class="language-plaintext highlighter-rouge">Chart:</code>) stays on the git path, because <code class="language-plaintext highlighter-rouge">IsHelm</code> keys on the chart, not on the presence of Helm value config. That distinction is the whole reason the fix is safe.</p>

<h2 id="proving-it-before-trusting-it">Proving it before trusting it</h2>

<p>The test is the part I never skip. I added a case to <code class="language-plaintext highlighter-rouge">TestUpdateRevisionForPaths</code> for an untyped Helm chart source carrying ref sources, and wired the git client mock so that any git resolution returns <code class="language-plaintext highlighter-rouge">failed to list refs: repository not found</code>. That means the test only passes if the git path is never entered. On master the new case fails with the exact error from the issue; with the fix it passes and does zero external cache work. A test that reproduces the reported error message before the fix, and goes green after, is the one I trust.</p>

<h2 id="an-aside-on-trusting-autofixes">An aside on trusting autofixes</h2>

<p>While iterating on this PR I accepted a Copilot autofix suggestion in the GitHub web UI, and it committed a dead-assignment lint error (<code class="language-plaintext highlighter-rouge">SA4006</code>) straight into the branch, plus a bit of working-tree contamination that leaked unrelated notification files into the diff. Accepting a web autofix commits it as-is, with no local gofmt, lint, or build in front of it. I caught both and cleaned them up, but the lesson stuck: an autofix is a suggestion, not a verified change. Run it through the same gate as anything you write by hand.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p><code class="language-plaintext highlighter-rouge">Normalize</code>-style helpers that fill in defaults are convenient right up to the moment a downstream decision depends on the difference between “unset” and “the default value.” Coercing empty-to-git erased exactly the signal the guard needed. When a default is applied, ask whether anything later treats the defaulted value as if the user had chosen it, because that is where the surprises live. The change is in <a href="https://github.com/argoproj/argo-cd/pull/28904">argoproj/argo-cd#28904</a>.</p>]]></content><author><name>Miguel Santos</name></author><category term="argo-cd" /><summary type="html"><![CDATA[Someone reported an Argo CD regression that only shows up in a fairly specific shape of Application: a multi-source app that combines an untyped Helm chart source with a git values source and the argocd.argoproj.io/manifest-generate-paths annotation. On v3.4.3 it started failing with:]]></summary></entry><entry><title type="html">Letting pyroscope.write decide what to do with a 429</title><link href="https://churi12.github.io/2026/07/letting-pyroscope-write-decide-what-to-do-with-a-429/" rel="alternate" type="text/html" title="Letting pyroscope.write decide what to do with a 429" /><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://churi12.github.io/2026/07/letting-pyroscope-write-decide-what-to-do-with-a-429</id><content type="html" xml:base="https://churi12.github.io/2026/07/letting-pyroscope-write-decide-what-to-do-with-a-429/"><![CDATA[<p>An Alloy issue asked for something small and reasonable: a way to tell <code class="language-plaintext highlighter-rouge">pyroscope.write</code> not to retry when the backend returns HTTP 429. By default it retries rate-limited profiles, which is usually right, but if you would rather drop them than pile more requests onto a backend that is already asking you to slow down, there was no way to say so. <code class="language-plaintext highlighter-rouge">loki.write</code> and <code class="language-plaintext highlighter-rouge">prometheus.remote_write</code> both already expose a <code class="language-plaintext highlighter-rouge">retry_on_http_429</code> flag. <code class="language-plaintext highlighter-rouge">pyroscope.write</code> did not. This is the kind of change I enjoy: the design decision is already made, you just have to match it.</p>

<h2 id="the-behavior-was-hardcoded">The behavior was hardcoded</h2>

<p>The retry decision lives in <code class="language-plaintext highlighter-rouge">shouldRetry</code>, and 429 was baked in alongside 408:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">shouldRetry</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">errors</span><span class="o">.</span><span class="n">Is</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="n">context</span><span class="o">.</span><span class="n">DeadlineExceeded</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">true</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="n">writeErr</span> <span class="o">*</span><span class="n">PyroscopeWriteError</span>
    <span class="k">if</span> <span class="n">errors</span><span class="o">.</span><span class="n">As</span><span class="p">(</span><span class="n">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">writeErr</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">status</span> <span class="o">:=</span> <span class="n">writeErr</span><span class="o">.</span><span class="n">StatusCode</span>
        <span class="k">if</span> <span class="n">status</span> <span class="o">==</span> <span class="n">http</span><span class="o">.</span><span class="n">StatusTooManyRequests</span> <span class="o">||</span> <span class="n">status</span> <span class="o">==</span> <span class="n">http</span><span class="o">.</span><span class="n">StatusRequestTimeout</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">true</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="n">status</span> <span class="o">&gt;=</span> <span class="n">http</span><span class="o">.</span><span class="n">StatusInternalServerError</span>
    <span class="p">}</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There is nothing wrong with the logic. It just was not a choice you could make from config.</p>

<h2 id="following-the-precedent-instead-of-inventing-one">Following the precedent instead of inventing one</h2>

<p>Because two sibling components already solved this, the interesting part is not the mechanism, it is matching their shape so the option feels native to Alloy. I added the field with the same name, same type, and the same default those components use:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">RetryOnHTTP429</span> <span class="kt">bool</span> <span class="s">`alloy:"retry_on_http_429,attr,optional"`</span>
</code></pre></div></div>

<p>Default <code class="language-plaintext highlighter-rouge">true</code>, set in <code class="language-plaintext highlighter-rouge">GetDefaultEndpointOptions</code>, so every existing config keeps behaving exactly as it does today. Then <code class="language-plaintext highlighter-rouge">shouldRetry</code> takes the flag and consults it only for 429:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">shouldRetry</span><span class="p">(</span><span class="n">err</span> <span class="kt">error</span><span class="p">,</span> <span class="n">retryOnHTTP429</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="k">if</span> <span class="n">status</span> <span class="o">==</span> <span class="n">http</span><span class="o">.</span><span class="n">StatusTooManyRequests</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">retryOnHTTP429</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">status</span> <span class="o">==</span> <span class="n">http</span><span class="o">.</span><span class="n">StatusRequestTimeout</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">true</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">status</span> <span class="o">&gt;=</span> <span class="n">http</span><span class="o">.</span><span class="n">StatusInternalServerError</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Splitting the combined <code class="language-plaintext highlighter-rouge">429 || 408</code> check into two is the whole change in spirit: 429 becomes configurable, while 408 and 5xx stay unconditionally retried, because those are not what the user asked to control. Both call sites, the push path and the ingest path, pass <code class="language-plaintext highlighter-rouge">ec.options.RetryOnHTTP429</code> through.</p>

<h2 id="proving-it-before-trusting-it">Proving it before trusting it</h2>

<p>I tested this at two levels. <code class="language-plaintext highlighter-rouge">shouldRetry</code> is pure, so I tested it directly: a 429 is retried with the flag on and dropped with it off, while 408, 503, and 400 return the same answer regardless of the flag. That last part matters as much as the feature itself, since it proves the new knob only touches 429 and leaves the other status codes alone.</p>

<p>Then, because a unit test of a predicate does not prove the flag is actually wired into the retry loop, I added two suite tests that stand up a server always returning 429 and count the requests. With <code class="language-plaintext highlighter-rouge">retry_on_http_429</code> disabled the profile is sent exactly once. With it enabled the request is retried up to <code class="language-plaintext highlighter-rouge">MaxBackoffRetries</code> times. Asserting on the observed request count is what convinces me the flag reaches the loop and not just the predicate.</p>

<h2 id="what-review-caught">What review caught</h2>

<p>The reviewer, agreeing with an automated review comment, pointed out something I had missed: <code class="language-plaintext highlighter-rouge">pyroscope.write</code> does not only talk HTTP. The push v1 path speaks Connect, and there a rate limit arrives as <code class="language-plaintext highlighter-rouge">connect.CodeResourceExhausted</code> rather than an HTTP 429. My first version left that path always retrying, so the new flag was only half honoured depending on which API the profile went out through. Same rate limit, same user intent, two code paths, and I had only found one. The fix was to gate the Connect code the same way, correct the docs to say plainly that 408 is always retried regardless of the flag, and add a test that drives <code class="language-plaintext highlighter-rouge">fanOutClient.Append</code> through a real Connect test server returning <code class="language-plaintext highlighter-rouge">CodeResourceExhausted</code>, because a unit test on the predicate would never have caught the gap. Worth remembering next time I add a knob to a component: find every transport the option is supposed to apply to before claiming it is done.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>When a project already has an established convention for a thing, the best contribution is often the least original one: find the precedent, match its name, its default, and its behavior, and make the new option indistinguishable from the ones that came before. A user who already knows <code class="language-plaintext highlighter-rouge">retry_on_http_429</code> from <code class="language-plaintext highlighter-rouge">loki.write</code> should not have to learn anything new to use it on <code class="language-plaintext highlighter-rouge">pyroscope.write</code>. Consistency across siblings is a feature. The change is in <a href="https://github.com/grafana/alloy/pull/6763">grafana/alloy#6763</a>, now merged.</p>]]></content><author><name>Miguel Santos</name></author><category term="alloy" /><summary type="html"><![CDATA[An Alloy issue asked for something small and reasonable: a way to tell pyroscope.write not to retry when the backend returns HTTP 429. By default it retries rate-limited profiles, which is usually right, but if you would rather drop them than pile more requests onto a backend that is already asking you to slow down, there was no way to say so. loki.write and prometheus.remote_write both already expose a retry_on_http_429 flag. pyroscope.write did not. This is the kind of change I enjoy: the design decision is already made, you just have to match it.]]></summary></entry><entry><title type="html">The cache was right there, just not reachable</title><link href="https://churi12.github.io/2026/07/the-cache-was-right-there-just-not-reachable/" rel="alternate" type="text/html" title="The cache was right there, just not reachable" /><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://churi12.github.io/2026/07/the-cache-was-right-there-just-not-reachable</id><content type="html" xml:base="https://churi12.github.io/2026/07/the-cache-was-right-there-just-not-reachable/"><![CDATA[<p>Argo CD’s notification controller had a performance regression in v3.4.x: with a lot of applications and triggers, it was making a large number of API calls per reconcile. The report traced it to AppProject resolution during template evaluation. Every trigger evaluation that needed the <code class="language-plaintext highlighter-rouge">appProject</code> template variable did a live API <code class="language-plaintext highlighter-rouge">Get</code> for the project. Multiply that by applications times triggers and the API server feels it.</p>

<p>What made this one interesting is that the fix was not “add a cache.” The cache already existed. It just could not be reached from the code that needed it.</p>

<h2 id="two-ways-to-fetch-the-same-appproject">Two ways to fetch the same AppProject</h2>

<p>The notification controller already runs a synced <code class="language-plaintext highlighter-rouge">AppProject</code> informer. There is even a helper, <code class="language-plaintext highlighter-rouge">getAppProj</code>, that reads projects straight out of that informer’s indexer. No API call, just a local cache lookup on an already-synced store.</p>

<p>But the template variables are built in a different package, <code class="language-plaintext highlighter-rouge">util/notification/settings</code>, by <code class="language-plaintext highlighter-rouge">getAppProjectForTemplate</code>. That function had no access to the informer, so it did the only thing it could:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ctx</span><span class="p">,</span> <span class="n">cancel</span> <span class="o">:=</span> <span class="n">context</span><span class="o">.</span><span class="n">WithTimeout</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">(),</span> <span class="m">5</span><span class="o">*</span><span class="n">time</span><span class="o">.</span><span class="n">Second</span><span class="p">)</span>
<span class="k">defer</span> <span class="n">cancel</span><span class="p">()</span>

<span class="n">appProjectObj</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">argocdService</span><span class="o">.</span><span class="n">GetAppProject</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">projectName</span><span class="p">,</span> <span class="n">namespace</span><span class="p">)</span>
</code></pre></div></div>

<p>A live <code class="language-plaintext highlighter-rouge">Get</code>, on every trigger evaluation. The fast path existed a package away; the settings package simply had no handle to it.</p>

<h2 id="threading-the-cache-in-without-forcing-it-on-everyone">Threading the cache in without forcing it on everyone</h2>

<p>The fix is to give the settings package an optional way to read from a cache, and let the controller supply one backed by its existing informer. I added a small function type:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// AppProjectGetter retrieves an AppProject from a local cache, avoiding a live</span>
<span class="c">// API call on every trigger evaluation. It returns a nil object without an</span>
<span class="c">// error when the AppProject is not present in the cache.</span>
<span class="k">type</span> <span class="n">AppProjectGetter</span> <span class="k">func</span><span class="p">(</span><span class="n">namespace</span><span class="p">,</span> <span class="n">name</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">unstructured</span><span class="o">.</span><span class="n">Unstructured</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">getAppProjectForTemplate</code> prefers it when present, and only falls back to the live lookup when it is nil:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">appProjectGetter</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
    <span class="n">appProjectObj</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">appProjectGetter</span><span class="p">(</span><span class="n">namespace</span><span class="p">,</span> <span class="n">projectName</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">log</span><span class="o">.</span><span class="n">WithFields</span><span class="p">(</span><span class="n">logFields</span><span class="p">)</span><span class="o">.</span><span class="n">Warnf</span><span class="p">(</span><span class="s">"Failed to get AppProject for notification template: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
        <span class="k">return</span> <span class="no">nil</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">appProjectObj</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">appProjectObj</span><span class="o">.</span><span class="n">Object</span>
<span class="p">}</span>

<span class="c">// ... otherwise the previous live Get, unchanged.</span>
</code></pre></div></div>

<p>The notification controller builds a getter that reads the informer’s indexer:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">newAppProjectGetter</span><span class="p">(</span><span class="n">appProjInformer</span> <span class="n">cache</span><span class="o">.</span><span class="n">SharedIndexInformer</span><span class="p">)</span> <span class="n">settings</span><span class="o">.</span><span class="n">AppProjectGetter</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">func</span><span class="p">(</span><span class="n">namespace</span><span class="p">,</span> <span class="n">name</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="n">unstructured</span><span class="o">.</span><span class="n">Unstructured</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">projObj</span><span class="p">,</span> <span class="n">exists</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">appProjInformer</span><span class="o">.</span><span class="n">GetIndexer</span><span class="p">()</span><span class="o">.</span><span class="n">GetByKey</span><span class="p">(</span><span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"%s/%s"</span><span class="p">,</span> <span class="n">namespace</span><span class="p">,</span> <span class="n">name</span><span class="p">))</span>
        <span class="c">// ... return the unstructured project, or nil if missing</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Callers that have no local cache, like the API server and the CLI, pass <code class="language-plaintext highlighter-rouge">nil</code> and keep the exact behavior they had before. So the controller, the hot path, gets the cache; everyone else is untouched.</p>

<h2 id="the-honest-note-about-the-signature">The honest note about the signature</h2>

<p><code class="language-plaintext highlighter-rouge">GetFactorySettings</code> is an exported function, and this change adds an <code class="language-plaintext highlighter-rouge">AppProjectGetter</code> parameter to it. I updated every in-tree caller, but if some out-of-tree code calls that function, this is a breaking change. I flagged that plainly in the PR body rather than hiding it. It seems like a reasonable cost for a real performance fix, but it is the maintainers’ call, and they should get to make it with the fact in front of them, not discover it later.</p>

<h2 id="proving-it-before-trusting-it">Proving it before trusting it</h2>

<p>The test I care most about here does not assert a return value, it asserts an absence. <code class="language-plaintext highlighter-rouge">TestGetAppProjectForTemplateUsesCache</code> provides a getter and a mock <code class="language-plaintext highlighter-rouge">Service</code> with no <code class="language-plaintext highlighter-rouge">GetAppProject</code> expectation configured at all. If the code ever fell back to the live lookup, the mock would fail the test. So the test proves the cache path is taken and the API is never touched, which is the whole point of the fix. A second test covers a cache miss returning nil without falling back. I confirmed both by reverting the cache branch and watching them fail.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>Sometimes the fix for “this does too much I/O” is not a new cache or a new client, it is a wire. The right data was already sitting in memory, synced and ready; it just was not plumbed to the caller that needed it. When you see a hot path doing live lookups, check whether something nearby already has the answer cached before you reach for a bigger hammer. The change is in <a href="https://github.com/argoproj/argo-cd/pull/28905">argoproj/argo-cd#28905</a>.</p>]]></content><author><name>Miguel Santos</name></author><category term="argo-cd" /><summary type="html"><![CDATA[Argo CD’s notification controller had a performance regression in v3.4.x: with a lot of applications and triggers, it was making a large number of API calls per reconcile. The report traced it to AppProject resolution during template evaluation. Every trigger evaluation that needed the appProject template variable did a live API Get for the project. Multiply that by applications times triggers and the API server feels it.]]></summary></entry><entry><title type="html">The label that wrapped</title><link href="https://churi12.github.io/2026/06/the-label-that-wrapped/" rel="alternate" type="text/html" title="The label that wrapped" /><published>2026-06-29T00:00:00+00:00</published><updated>2026-06-29T00:00:00+00:00</updated><id>https://churi12.github.io/2026/06/the-label-that-wrapped</id><content type="html" xml:base="https://churi12.github.io/2026/06/the-label-that-wrapped/"><![CDATA[<p>Someone configured the Tempo data source to show <code class="language-plaintext highlighter-rouge">resource.k8s.cluster_name</code> and <code class="language-plaintext highlighter-rouge">resource.k8s.namespace</code> as static search fields in Explore, and the labels overflowed their column. The screenshot in the issue is the whole bug: a tidy form with two field labels that are too long for the space they were given, wrapping and colliding with the inputs next to them. The ask was simple and reasonable — let me rename these. It turned out the reason you could not rename them is the same reason they were too long in the first place.</p>

<h2 id="where-the-label-comes-from">Where the label comes from</h2>

<p>Static search fields do not store a label. They store a tag, and the label is computed from the tag every time the editor renders. That computation lives in <code class="language-plaintext highlighter-rouge">filterTitle</code>:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">filterTitle</span> <span class="o">=</span> <span class="p">(</span><span class="nx">f</span><span class="p">:</span> <span class="nx">TraceqlFilter</span><span class="p">,</span> <span class="nx">lp</span><span class="p">:</span> <span class="nx">TempoLanguageProvider</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="c1">// Special case for the intrinsic "name"</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">tag</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">name</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="dl">'</span><span class="s1">Span Name</span><span class="dl">'</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="c1">// Special case for the resource service name</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">tag</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">service.name</span><span class="dl">'</span> <span class="o">&amp;&amp;</span> <span class="nx">f</span><span class="p">.</span><span class="nx">scope</span> <span class="o">===</span> <span class="nx">TraceqlSearchScope</span><span class="p">.</span><span class="nx">Resource</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="dl">'</span><span class="s1">Service Name</span><span class="dl">'</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="nf">startCase</span><span class="p">(</span><span class="nf">filterScopedTag</span><span class="p">(</span><span class="nx">f</span><span class="p">,</span> <span class="nx">lp</span><span class="p">));</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The last line is the general case, and it is where the length comes from. <code class="language-plaintext highlighter-rouge">filterScopedTag</code> prepends the scope, so the tag becomes <code class="language-plaintext highlighter-rouge">resource.k8s.cluster.name</code>, and then lodash <code class="language-plaintext highlighter-rouge">startCase</code> runs over it. I had a guess about what that produces and I was wrong, so I ran it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>startCase('resource.k8s.cluster.name')  // "Resource K 8 S Cluster Name"
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">startCase</code> splits on the dots and on the digit boundary inside <code class="language-plaintext highlighter-rouge">k8s</code>, capitalizes each piece, and joins with spaces. So a perfectly normal Kubernetes attribute renders as <code class="language-plaintext highlighter-rouge">Resource K 8 S Cluster Name</code>. That string is long, and it is also slightly mangled — there is no label you could choose for a k8s attribute that <code class="language-plaintext highlighter-rouge">startCase</code> would not stretch out.</p>

<h2 id="where-the-length-hurts">Where the length hurts</h2>

<p>That title is handed to an <code class="language-plaintext highlighter-rouge">InlineField</code> with a fixed label width:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">InlineField</span> <span class="na">label</span><span class="p">=</span><span class="si">{</span><span class="nx">label</span><span class="si">}</span> <span class="na">labelWidth</span><span class="p">=</span><span class="si">{</span><span class="mi">28</span><span class="si">}</span> <span class="na">grow</span> <span class="na">tooltip</span><span class="p">=</span><span class="si">{</span><span class="nx">tooltip</span><span class="si">}</span><span class="p">&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">labelWidth={28}</code> is a fixed column. A short label fits; <code class="language-plaintext highlighter-rouge">Resource K 8 S Cluster Name</code> does not, so it wraps, and once it wraps it pushes into the row below. The form has no idea the label is auto-generated and possibly too long — it just lays out what it is given.</p>

<p>So there are two real problems stacked on top of each other: the label is derived rather than chosen, and the column it goes into cannot flex. The issue asked to fix the first one.</p>

<h2 id="the-fix-let-the-field-carry-a-name">The fix: let the field carry a name</h2>

<p>The change is to give a static field an optional label and prefer it when it is set. One field on the filter model:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kr">interface</span> <span class="nx">TraceqlFilter</span> <span class="p">{</span>
  <span class="c1">// ...</span>
  <span class="cm">/**
   * A custom label to display for the filter in the search editor.
   * When set, it overrides the auto-generated title derived from the tag.
   */</span>
  <span class="nl">label</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One early return at the top of <code class="language-plaintext highlighter-rouge">filterTitle</code>, before any of the special cases:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">label</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">label</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And an input on the data source configuration page where you define these static fields, so you can actually type the name in. That input is gated behind a <code class="language-plaintext highlighter-rouge">showLabel</code> prop threaded through the components, so it shows up where you configure the fields and not in the query editor itself. The query editor needs no changes at all: it already renders through <code class="language-plaintext highlighter-rouge">filterTitle</code>, so the moment a label exists, the editor picks it up.</p>

<p>The whole thing is additive. A field with no label behaves exactly as before — same <code class="language-plaintext highlighter-rouge">startCase</code>, same special cases for <code class="language-plaintext highlighter-rouge">name</code> and <code class="language-plaintext highlighter-rouge">service.name</code>. Nothing in the stored query changes. You opt in per field, or you ignore it and keep the old behavior.</p>

<h2 id="on-the-other-fix">On the other fix</h2>

<p>There is an earlier comment on the issue suggesting the real fix is to drop the fixed label width, or let the row grow so wrapping does not overflow. That is a good idea and I said so in the PR. It is a CSS fix to the second problem — the column that cannot flex — and it would help every label, including the auto-generated ones, without anyone having to type anything. It is complementary, not competing.</p>

<p>I went with the rename because it is what the issue asked for, and because a later commenter confirmed a custom label would solve their case. A flexible column makes a long name fit; a custom label lets you decide the name should not be long in the first place. <code class="language-plaintext highlighter-rouge">Resource K 8 S Cluster Name</code> becoming <code class="language-plaintext highlighter-rouge">Cluster</code> is a better outcome than <code class="language-plaintext highlighter-rouge">Resource K 8 S Cluster Name</code> fitting more gracefully. Both can ship; neither blocks the other.</p>

<h2 id="checking-the-derivation-not-just-the-override">Checking the derivation, not just the override</h2>

<p>The tests cover the new path — a label wins, and it wins even over the <code class="language-plaintext highlighter-rouge">name</code> special case — but the one I cared most about is the fallback, because that is the assertion I had wrong in my head. The test pins <code class="language-plaintext highlighter-rouge">filterTitle</code> with no label to <code class="language-plaintext highlighter-rouge">Resource K 8 S Cluster Name</code>, the actual <code class="language-plaintext highlighter-rouge">startCase</code> output, not the cleaned-up version I assumed. If someone changes how titles are derived, that test fails and tells them the visible label changed. Writing down the ugly real string is more useful than writing down the pretty wrong one.</p>

<p>The change is in <a href="https://github.com/grafana/grafana-tempo-datasource/pull/205">grafana/grafana-tempo-datasource#205</a>, now merged.</p>]]></content><author><name>Miguel Santos</name></author><category term="tempo" /><summary type="html"><![CDATA[Someone configured the Tempo data source to show resource.k8s.cluster_name and resource.k8s.namespace as static search fields in Explore, and the labels overflowed their column. The screenshot in the issue is the whole bug: a tidy form with two field labels that are too long for the space they were given, wrapping and colliding with the inputs next to them. The ask was simple and reasonable — let me rename these. It turned out the reason you could not rename them is the same reason they were too long in the first place.]]></summary></entry><entry><title type="html">A CIDR and a list of /32s should be the same thing, but one broke the sidecar</title><link href="https://churi12.github.io/2026/06/a-cidr-and-a-slash-32-list-should-be-the-same-thing/" rel="alternate" type="text/html" title="A CIDR and a list of /32s should be the same thing, but one broke the sidecar" /><published>2026-06-28T00:00:00+00:00</published><updated>2026-06-28T00:00:00+00:00</updated><id>https://churi12.github.io/2026/06/a-cidr-and-a-slash-32-list-should-be-the-same-thing</id><content type="html" xml:base="https://churi12.github.io/2026/06/a-cidr-and-a-slash-32-list-should-be-the-same-thing/"><![CDATA[<p>Someone reported a ServiceEntry that broke their sidecar in a way that made no sense at first. Write the addresses one way and the pod starts. Write the same addresses a different way and the pod never goes ready. The two forms cover the exact same eight IPs:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># works: eight explicit hosts</span>
<span class="na">addresses</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">10.80.68.0/32</span>
<span class="pi">-</span> <span class="s">10.80.68.1/32</span>
<span class="c1"># ... through .7/32</span>

<span class="c1"># fails: one CIDR for the same eight addresses</span>
<span class="na">addresses</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">10.80.68.0/29</span>
</code></pre></div></div>

<p>With the <code class="language-plaintext highlighter-rouge">/29</code>, the sidecar logs a listener it cannot accept:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>error adding listener: 'virtualOutbound' has duplicate address '0.0.0.0:15001' as existing listener
Listener rejected
Envoy proxy is NOT ready: lds updates: 0 successful, 1 rejected
</code></pre></div></div>

<p>One rejected listener fails the whole LDS update, so the proxy never becomes ready and the pod is stuck. The clue is in the address: <code class="language-plaintext highlighter-rouge">0.0.0.0:15001</code>. Port 15001 is istio’s own outbound capture port, the <code class="language-plaintext highlighter-rouge">virtualOutbound</code> listener. Somehow this ServiceEntry was generating a second listener on top of it.</p>

<h2 id="why-the-two-forms-diverge">Why the two forms diverge</h2>

<p>When istio builds an outbound listener for a service, it has to decide what address to bind. If the service has a concrete IP, it binds that IP. If the address is a CIDR, you cannot bind a range, so it falls back to the wildcard <code class="language-plaintext highlighter-rouge">0.0.0.0</code> and adds a filter chain match for the CIDR instead:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">!</span><span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">svcListenAddress</span><span class="p">,</span> <span class="s">"/"</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">listenerOpts</span><span class="o">.</span><span class="n">bind</span><span class="o">.</span><span class="n">binds</span> <span class="o">=</span> <span class="nb">append</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="n">svcListenAddress</span><span class="p">},</span> <span class="n">svcExtraListenAddresses</span><span class="o">...</span><span class="p">)</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="c">// Address is a CIDR. Fall back to 0.0.0.0 and filter chain match</span>
    <span class="n">listenerOpts</span><span class="o">.</span><span class="n">bind</span><span class="o">.</span><span class="n">binds</span> <span class="o">=</span> <span class="n">actualWildcards</span>
    <span class="n">listenerOpts</span><span class="o">.</span><span class="n">cidr</span> <span class="o">=</span> <span class="o">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That is the whole divergence. Eight <code class="language-plaintext highlighter-rouge">/32</code> entries are concrete addresses, so each binds <code class="language-plaintext highlighter-rouge">10.80.68.x:15001</code>, none of which collide with anything. One <code class="language-plaintext highlighter-rouge">/29</code> is a CIDR, so it binds <code class="language-plaintext highlighter-rouge">0.0.0.0:15001</code>, which is precisely where <code class="language-plaintext highlighter-rouge">virtualOutbound</code> already lives. Same eight IPs, completely different listener address, and only one of them steps on a reserved port.</p>

<h2 id="there-is-a-guard-and-the-cidr-walked-around-it">There is a guard, and the CIDR walked around it</h2>

<p>Istio is not naive about service ports clashing with its own. There is a check, <code class="language-plaintext highlighter-rouge">conflictWithReservedListener</code>, that knows about 15001 and the other reserved ports, and the listener-building loop already skips services that conflict. So why did this one slip through? Look at how the check bails out early:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">bind</span> <span class="o">!=</span> <span class="s">""</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">bind</span> <span class="o">!=</span> <span class="n">wildcard</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">false</span>
    <span class="p">}</span>
<span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="o">!</span><span class="n">protocol</span><span class="o">.</span><span class="n">IsHTTP</span><span class="p">()</span> <span class="p">{</span>
    <span class="c">// bind unspecified and not HTTP: assume it will bind its own address</span>
    <span class="k">return</span> <span class="no">false</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The check runs before the CIDR fallback has happened. At that point the bind is still empty and the protocol is TLS, not HTTP, so it takes the early <code class="language-plaintext highlighter-rouge">return false</code>: no conflict, this service will bind its own address. That assumption is true for a normal service. It is false for a CIDR service, which is about to be rewritten to the wildcard a few lines later. The guard made its decision based on a bind address that the code then changed out from under it.</p>

<h2 id="fix-it-where-the-decision-actually-becomes-true">Fix it where the decision actually becomes true</h2>

<p>The conflict only becomes real at the moment the listener falls back to the wildcard, so that is where I put the re-check. Right after the CIDR branch sets the wildcard bind:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">listenerOpts</span><span class="o">.</span><span class="n">bind</span><span class="o">.</span><span class="n">binds</span> <span class="o">=</span> <span class="n">actualWildcards</span>
<span class="n">listenerOpts</span><span class="o">.</span><span class="n">cidr</span> <span class="o">=</span> <span class="nb">append</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="n">svcListenAddress</span><span class="p">},</span> <span class="n">svcExtraListenAddresses</span><span class="o">...</span><span class="p">)</span>
<span class="c">// Now that we are binding to the wildcard address, this listener can</span>
<span class="c">// collide with a reserved listener ... re-check here and skip the port</span>
<span class="c">// if it conflicts.</span>
<span class="n">wildcard</span> <span class="o">:=</span> <span class="n">actualWildcards</span><span class="p">[</span><span class="m">0</span><span class="p">]</span>
<span class="k">if</span> <span class="n">canbind</span><span class="p">,</span> <span class="n">knownlistener</span> <span class="o">:=</span> <span class="n">lb</span><span class="o">.</span><span class="n">node</span><span class="o">.</span><span class="n">CanBindToPort</span><span class="p">(</span><span class="o">...</span><span class="p">);</span> <span class="o">!</span><span class="n">canbind</span> <span class="p">{</span>
    <span class="o">...</span>
    <span class="k">return</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now a CIDR ServiceEntry on a reserved port is skipped, exactly like a concrete-address service on that port already was. It reuses the same <code class="language-plaintext highlighter-rouge">CanBindToPort</code> the rest of the loop uses, so the behaviour matches rather than inventing a second notion of what conflicts.</p>

<h2 id="proving-it-before-trusting-it">Proving it before trusting it</h2>

<p>Before writing the fix I wrote the reproduction, because I wanted to see the bad listener with my own eyes, not just trust the report. The test harness has a <code class="language-plaintext highlighter-rouge">ValidateListeners</code> helper that already flags duplicate addresses, so a throwaway test that built a CIDR ServiceEntry on port 15001 was enough: it produced <code class="language-plaintext highlighter-rouge">0.0.0.0_15001</code> and failed, the bug, in a unit test, in under two seconds.</p>

<p>Then I turned that into a proper test with three cases: a CIDR service on 15001 and on 15006 must produce no listener, and a CIDR service on an ordinary port must still get its wildcard listener. That last case matters as much as the first two, because the easy wrong fix is to drop CIDR wildcard listeners too eagerly and quietly break the normal case. I reverted the fix and watched the two reserved-port cases fail while the ordinary-port case kept passing, then put it back and all three passed.</p>

<p>(One small note to my future self: I first wrote that throwaway test with a shell heredoc, and the colorized shell snuck an escape byte into the <code class="language-plaintext highlighter-rouge">.go</code> file, which the compiler rejected as an illegal character. Write Go from an editor, not a heredoc.)</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>When a value gets rewritten partway through a function, any check that ran before the rewrite was answering a question about a state that no longer exists. The conflict guard here was correct for the bind it saw and wrong for the bind the code produced. The fix was not to make the early check smarter about the future, but to ask the question again at the point where the answer actually settles. And the reproduction-first habit paid for itself twice: it confirmed the bug was real before I touched anything, and it caught the over-eager version of the fix that would have broken ordinary CIDR services. The change is in <a href="https://github.com/istio/istio/pull/60722">istio/istio#60722</a>.</p>]]></content><author><name>Miguel Santos</name></author><category term="istio" /><summary type="html"><![CDATA[Someone reported a ServiceEntry that broke their sidecar in a way that made no sense at first. Write the addresses one way and the pod starts. Write the same addresses a different way and the pod never goes ready. The two forms cover the exact same eight IPs:]]></summary></entry><entry><title type="html">Istio built the validation context, then refused to authorize it</title><link href="https://churi12.github.io/2026/06/istio-built-the-validation-context-then-refused-to-authorize-it/" rel="alternate" type="text/html" title="Istio built the validation context, then refused to authorize it" /><published>2026-06-28T00:00:00+00:00</published><updated>2026-06-28T00:00:00+00:00</updated><id>https://churi12.github.io/2026/06/istio-built-the-validation-context-then-refused-to-authorize-it</id><content type="html" xml:base="https://churi12.github.io/2026/06/istio-built-the-validation-context-then-refused-to-authorize-it/"><![CDATA[<p>A Gateway API listener using <code class="language-plaintext highlighter-rouge">OPTIONAL_MUTUAL</code> TLS termination was resetting every connection. The same listener configured as <code class="language-plaintext highlighter-rouge">MUTUAL</code>, with the identical secret in the identical namespace, worked. The only difference was one enum value, and that was enough to make istiod log this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>warn ads proxy &lt;pod&gt;.&lt;ns&gt; attempted to access unauthorized certificates &lt;cred&gt;-cacert: cross namespace secret reference requires ReferenceGrant
</code></pre></div></div>

<p>The cert secret holds <code class="language-plaintext highlighter-rouge">tls.crt</code>, <code class="language-plaintext highlighter-rouge">tls.key</code>, and <code class="language-plaintext highlighter-rouge">ca.crt</code>. Istio splits that into two SDS resources: the cert itself, and a <code class="language-plaintext highlighter-rouge">-cacert</code> resource for the CA bundle that validates client certificates. The handshake needs both. <code class="language-plaintext highlighter-rouge">istioctl pc secret</code> showed the <code class="language-plaintext highlighter-rouge">-cacert</code> resource stuck in <code class="language-plaintext highlighter-rouge">WARMING</code>, never delivered, because SDS said the proxy was not allowed to have it.</p>

<h2 id="two-places-decide-and-they-disagreed">Two places decide, and they disagreed</h2>

<p>The thing that makes this bug tidy is that the listener is built from one set of rules and authorized by another, and the two had drifted apart.</p>

<p>When istiod builds the TLS context for the listener, it wires the <code class="language-plaintext highlighter-rouge">-cacert</code> validation context for both mutual modes. That code is explicit about it:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// If tls mode is MUTUAL/OPTIONAL_MUTUAL, create SDS config for gateway/sidecar to fetch certificate validation context</span>
<span class="k">if</span> <span class="n">tlsOpts</span><span class="o">.</span><span class="n">Mode</span> <span class="o">==</span> <span class="n">networking</span><span class="o">.</span><span class="n">ServerTLSSettings_MUTUAL</span> <span class="o">||</span> <span class="n">tlsOpts</span><span class="o">.</span><span class="n">Mode</span> <span class="o">==</span> <span class="n">networking</span><span class="o">.</span><span class="n">ServerTLSSettings_OPTIONAL_MUTUAL</span> <span class="p">{</span>
</code></pre></div></div>

<p>So the listener that gets pushed to Envoy references <code class="language-plaintext highlighter-rouge">&lt;cred&gt;-cacert</code>. Envoy asks SDS for it. SDS checks the request against the set of credentials the proxy is allowed to see, <code class="language-plaintext highlighter-rouge">VerifiedCertificateReferences</code>, and that set is built somewhere else entirely, in <code class="language-plaintext highlighter-rouge">mergeGateways</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">verifiedCertificateReferences</span><span class="o">.</span><span class="n">Insert</span><span class="p">(</span><span class="n">rn</span><span class="p">)</span>
<span class="k">if</span> <span class="n">s</span><span class="o">.</span><span class="n">GetTls</span><span class="p">()</span><span class="o">.</span><span class="n">GetMode</span><span class="p">()</span> <span class="o">==</span> <span class="n">networking</span><span class="o">.</span><span class="n">ServerTLSSettings_MUTUAL</span> <span class="p">{</span>
    <span class="n">verifiedCertificateReferences</span><span class="o">.</span><span class="n">Insert</span><span class="p">(</span><span class="n">rn</span> <span class="o">+</span> <span class="n">credentials</span><span class="o">.</span><span class="n">SdsCaSuffix</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The plain cert reference is always added. The <code class="language-plaintext highlighter-rouge">-cacert</code> suffix is added only for <code class="language-plaintext highlighter-rouge">MUTUAL</code>. For <code class="language-plaintext highlighter-rouge">OPTIONAL_MUTUAL</code> the listener asks for a credential that the authorization step never put on the allow-list. One half of the code believed in <code class="language-plaintext highlighter-rouge">OPTIONAL_MUTUAL</code>, the other half had never heard of it.</p>

<p>That also explains why a <code class="language-plaintext highlighter-rouge">ReferenceGrant</code> did not help. There are two branches in <code class="language-plaintext highlighter-rouge">mergeGateways</code>, same-namespace and explicitly-allowed-by-policy, and both gated the <code class="language-plaintext highlighter-rouge">-cacert</code> insert on <code class="language-plaintext highlighter-rouge">MUTUAL</code>. No grant could open a door that neither branch was willing to add.</p>

<h2 id="the-fix-is-to-make-both-sides-agree">The fix is to make both sides agree</h2>

<p>The authorization step should authorize the same modes the listener-build step builds for. So both inserts now match the expression already used when constructing the context:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">s</span><span class="o">.</span><span class="n">GetTls</span><span class="p">()</span><span class="o">.</span><span class="n">GetMode</span><span class="p">()</span> <span class="o">==</span> <span class="n">networking</span><span class="o">.</span><span class="n">ServerTLSSettings_MUTUAL</span> <span class="o">||</span>
    <span class="n">s</span><span class="o">.</span><span class="n">GetTls</span><span class="p">()</span><span class="o">.</span><span class="n">GetMode</span><span class="p">()</span> <span class="o">==</span> <span class="n">networking</span><span class="o">.</span><span class="n">ServerTLSSettings_OPTIONAL_MUTUAL</span> <span class="p">{</span>
    <span class="n">verifiedCertificateReferences</span><span class="o">.</span><span class="n">Insert</span><span class="p">(</span><span class="n">rn</span> <span class="o">+</span> <span class="n">credentials</span><span class="o">.</span><span class="n">SdsCaSuffix</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is not a new idea in the codebase, which is what gave me confidence it would be accepted. The exact same bug existed for <code class="language-plaintext highlighter-rouge">MUTUAL</code> once, where the validation context was built but the reference was not authorized, and it was fixed the same way. <code class="language-plaintext highlighter-rouge">OPTIONAL_MUTUAL</code> is just the mode that got left behind when that earlier fix went in. The change is the analog of the one already merged, applied to the mode nobody circled back to.</p>

<h2 id="proving-it-before-trusting-it">Proving it before trusting it</h2>

<p>The merge logic has a table-driven test that counts how many references end up authorized. The existing <code class="language-plaintext highlighter-rouge">mutual-cred-in-allowed-ns</code> case expects two: the cert and its <code class="language-plaintext highlighter-rouge">-cacert</code>. I added an <code class="language-plaintext highlighter-rouge">optional-mutual-cred-in-allowed-ns</code> case that is identical except for the mode, and it expects two as well.</p>

<p>Then the step I never skip: I reverted the one-line fix and ran the test. The new case failed with <code class="language-plaintext highlighter-rouge">Expected: 2 Got: 1</code>, which is the bug stated in numbers, the cert authorized and the CA bundle not. Put the fix back and it reads two. A test you have not watched fail is decoration, not a guard.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>When one part of a system decides what to build and a different part decides what to permit, every new case has to be taught to both, and a list-of-known-modes is exactly the kind of place where one side gets updated and the other does not. The smell here was specific: the same enum, <code class="language-plaintext highlighter-rouge">OPTIONAL_MUTUAL</code>, named in the context-building code and absent two functions away in the authorization code. Grepping for the places a mode is mentioned, and asking why one site has it and another does not, is often faster than reading the data flow end to end. The change is in <a href="https://github.com/istio/istio/pull/60720">istio/istio#60720</a>.</p>]]></content><author><name>Miguel Santos</name></author><category term="istio" /><summary type="html"><![CDATA[A Gateway API listener using OPTIONAL_MUTUAL TLS termination was resetting every connection. The same listener configured as MUTUAL, with the identical secret in the identical namespace, worked. The only difference was one enum value, and that was enough to make istiod log this:]]></summary></entry><entry><title type="html">The CNI pod was waiting for a file only it could write</title><link href="https://churi12.github.io/2026/06/the-cni-pod-was-waiting-for-a-file-only-it-could-write/" rel="alternate" type="text/html" title="The CNI pod was waiting for a file only it could write" /><published>2026-06-28T00:00:00+00:00</published><updated>2026-06-28T00:00:00+00:00</updated><id>https://churi12.github.io/2026/06/the-cni-pod-was-waiting-for-a-file-only-it-could-write</id><content type="html" xml:base="https://churi12.github.io/2026/06/the-cni-pod-was-waiting-for-a-file-only-it-could-write/"><![CDATA[<p>A node reboots. Every pod that tries to come back up fails to get a network:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>failed to setup network for sandbox ...: ... istio-cni-kubeconfig: no such file or directory
</code></pre></div></div>

<p>The file that is missing, <code class="language-plaintext highlighter-rouge">istio-cni-kubeconfig</code>, is written by the istio-cni node agent. So the agent needs to start, write the file, and then everything else can use it. The catch is that the agent is also a pod, and bringing up a pod runs the CNI plugin, which wants to read that same kubeconfig. The thing that creates the file is blocked behind the file it creates. Classic deadlock, and a reboot is the moment it bites because nothing has written the file yet.</p>

<h2 id="someone-already-saw-this-coming">Someone already saw this coming</h2>

<p>What is interesting is that the code already had the escape hatch. The CNI plugin’s <code class="language-plaintext highlighter-rouge">CmdAdd</code> does a preemptive check: before it builds a Kubernetes client, it asks “is the pod I am being called for actually my own node agent?” If so, it returns early and never touches the kubeconfig. The comment spells out the reasoning exactly:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Preemptively check if the pod is a CNI pod.</span>
<span class="c">// It is possible that the kubeconfig is not available if it hasn't been written yet</span>
<span class="c">// by the CNI pod ... to avoid a deadlock on the kubeconfig when the k8s client is</span>
<span class="c">// unnecessary to process the CNI add event for the CNI pod itself.</span>
</code></pre></div></div>

<p>That is the deadlock, named and defended against. The detection is a small helper that checks whether the pod name has the <code class="language-plaintext highlighter-rouge">istio-cni-node-</code> prefix and lives in the agent’s own namespace. If it does, skip the client entirely and let the pod through.</p>

<h2 id="the-guard-only-ran-in-one-mode">The guard only ran in one mode</h2>

<p>Here is the line that turned a fixed bug back into an open one:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">conf</span><span class="o">.</span><span class="n">AmbientEnabled</span> <span class="p">{</span>
    <span class="n">k8sArgs</span> <span class="o">:=</span> <span class="n">K8sArgs</span><span class="p">{}</span>
    <span class="o">...</span>
    <span class="k">if</span> <span class="n">isCNIPod</span><span class="p">(</span><span class="n">conf</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">k8sArgs</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">pluginResponse</span><span class="p">(</span><span class="n">conf</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The whole preemptive check sits inside <code class="language-plaintext highlighter-rouge">if conf.AmbientEnabled</code>. So the deadlock guard only fires for clusters running ambient mode. A plain sidecar-mode cluster never runs it, and right after this block the code does what it always does:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">client</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">newK8sClient</span><span class="p">(</span><span class="o">*</span><span class="n">conf</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">newK8sClient</code> runs unconditionally, in every mode. It is the call that blocks on the missing kubeconfig. So the guard protects against a deadlock that can happen in both modes, but only in the one mode it happens to be nested under. The reasoning in the comment is not specific to ambient at all, it is about the agent pod needing a client it is not yet able to build. The condition around it was narrower than the problem it described.</p>

<h2 id="the-fix-is-to-stop-nesting-it">The fix is to stop nesting it</h2>

<p>The check has nothing to do with ambient mode, so it should not be gated by it. I lifted it out:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">k8sArgs</span> <span class="o">:=</span> <span class="n">K8sArgs</span><span class="p">{}</span>
<span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">types</span><span class="o">.</span><span class="n">LoadArgs</span><span class="p">(</span><span class="n">args</span><span class="o">.</span><span class="n">Args</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">k8sArgs</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"failed to load args: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
<span class="p">}</span>
<span class="k">if</span> <span class="n">isCNIPod</span><span class="p">(</span><span class="n">conf</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">k8sArgs</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">pluginResponse</span><span class="p">(</span><span class="n">conf</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now the agent pod is recognised and skipped before the client is built, regardless of mode. The conditional was load-bearing for ambient only by accident; the behaviour it guards belongs to both.</p>

<h2 id="proving-it-before-trusting-it-on-linux">Proving it before trusting it, on Linux</h2>

<p>The CNI package is full of Linux network-namespace code, so the tests do not even compile on my Mac. I ran them in a <code class="language-plaintext highlighter-rouge">golang:1.26</code> container instead, which is worth knowing if you ever touch this corner of istio: a green run locally on macOS is not available to you here, you have to get to Linux somehow.</p>

<p>The test calls <code class="language-plaintext highlighter-rouge">CmdAdd</code> for a pod named like the node agent, in both ambient and sidecar mode, and asserts it returns without error. The mock kubeconfig does not exist, so if the code reaches <code class="language-plaintext highlighter-rouge">newK8sClient</code> the test fails. I ran it both ways. With the fix, both modes pass. With the guard still nested under <code class="language-plaintext highlighter-rouge">AmbientEnabled</code>, the ambient subtest passes and the sidecar subtest fails, reaching the client and erroring out, which is the deadlock reproduced in a unit test. The two-mode split in the result is the bug, drawn precisely.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>A fix that is correct but scoped too narrowly is its own kind of bug, and it is harder to spot than a missing fix because the defensive code is right there, commented, clearly intended. The tell was the mismatch between the comment and the condition: the comment justified the check in mode-neutral terms while the <code class="language-plaintext highlighter-rouge">if</code> wrapped it in one mode. When a guard’s rationale is broader than the branch it lives in, the branch is usually wrong, not the rationale. The change is in <a href="https://github.com/istio/istio/pull/60721">istio/istio#60721</a>.</p>]]></content><author><name>Miguel Santos</name></author><category term="istio" /><summary type="html"><![CDATA[A node reboots. Every pod that tries to come back up fails to get a network:]]></summary></entry><entry><title type="html">A secret got printed as a Go struct into my scrape target</title><link href="https://churi12.github.io/2026/06/a-secret-got-printed-as-a-go-struct-into-my-scrape-target/" rel="alternate" type="text/html" title="A secret got printed as a Go struct into my scrape target" /><published>2026-06-27T00:00:00+00:00</published><updated>2026-06-27T00:00:00+00:00</updated><id>https://churi12.github.io/2026/06/a-secret-got-printed-as-a-go-struct-into-my-scrape-target</id><content type="html" xml:base="https://churi12.github.io/2026/06/a-secret-got-printed-as-a-go-struct-into-my-scrape-target/"><![CDATA[<p>Someone reported an Alloy config where they read a value out of a file and used it as a scrape target address. Instead of the address, the target came out as <code class="language-plaintext highlighter-rouge">{true 10.0.0.1:9090}</code> — the IP wrapped in what is unmistakably a Go struct printed with <code class="language-plaintext highlighter-rouge">%v</code>. The workaround they found was to wrap the value in <code class="language-plaintext highlighter-rouge">convert.nonsensitive()</code>, which is a strong hint about where to look. This is the kind of bug I like: the symptom names the cause if you know the codebase, and the fix is a few lines once you see it.</p>

<h2 id="the-setup-that-breaks">The setup that breaks</h2>

<p>The config reads a file and feeds its content into a <code class="language-plaintext highlighter-rouge">discovery.relabel</code> target:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>local.file "service_ip" {
  filename  = "/etc/alloy/service_ip"
  is_secret = true
}

discovery.relabel "backend" {
  targets = [{
    __address__ = local.file.service_ip.content,
  }]
}
</code></pre></div></div>

<p>You expect <code class="language-plaintext highlighter-rouge">__address__</code> to be the file’s contents. You get <code class="language-plaintext highlighter-rouge">{true 10.0.0.1:9090}</code>. Flip <code class="language-plaintext highlighter-rouge">is_secret</code> to <code class="language-plaintext highlighter-rouge">false</code> and it becomes <code class="language-plaintext highlighter-rouge">{false 10.0.0.1:9090}</code>. That <code class="language-plaintext highlighter-rouge">true</code>/<code class="language-plaintext highlighter-rouge">false</code> leading the value is the tell.</p>

<h2 id="why-a-string-turns-into-a-struct">Why a string turns into a struct</h2>

<p><code class="language-plaintext highlighter-rouge">local.file</code> does not export its content as a plain string. It exports an <code class="language-plaintext highlighter-rouge">alloytypes.OptionalSecret</code>, which is a small struct:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">OptionalSecret</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">IsSecret</span> <span class="kt">bool</span>
	<span class="n">Value</span>    <span class="kt">string</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That is so Alloy can carry “this might be sensitive” alongside the value and redact it when rendering config. It is a capsule type, not a string.</p>

<p>Targets are built in <code class="language-plaintext highlighter-rouge">discovery</code>’s <code class="language-plaintext highlighter-rouge">Target.ConvertFrom</code>. When it walks the map of target values, it handles strings and then falls back to formatting anything else:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="n">v</span><span class="o">.</span><span class="n">IsString</span><span class="p">()</span><span class="o">:</span>
	<span class="n">strValue</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">Text</span><span class="p">()</span>
<span class="k">case</span> <span class="n">v</span><span class="o">.</span><span class="n">Reflect</span><span class="p">()</span><span class="o">.</span><span class="n">CanInterface</span><span class="p">()</span><span class="o">:</span>
	<span class="n">strValue</span> <span class="o">=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"%v"</span><span class="p">,</span> <span class="n">v</span><span class="o">.</span><span class="n">Reflect</span><span class="p">()</span><span class="o">.</span><span class="n">Interface</span><span class="p">())</span>
</code></pre></div></div>

<p>An <code class="language-plaintext highlighter-rouge">OptionalSecret</code> is not a string, so it hits the second branch, and <code class="language-plaintext highlighter-rouge">%v</code> on a struct gives you exactly <code class="language-plaintext highlighter-rouge">{true 10.0.0.1:9090}</code>. The boolean is <code class="language-plaintext highlighter-rouge">IsSecret</code>, the rest is <code class="language-plaintext highlighter-rouge">Value</code>. The code was never wrong about the value — it just printed the whole box instead of what was inside it. And <code class="language-plaintext highlighter-rouge">convert.nonsensitive()</code> worked around it precisely because it turned the capsule back into a plain string before it reached this branch.</p>

<h2 id="the-fix-unwrap-the-box">The fix: unwrap the box</h2>

<p>The two capsule types that wrap a string here are <code class="language-plaintext highlighter-rouge">OptionalSecret</code> and <code class="language-plaintext highlighter-rouge">Secret</code>. The fix is to pull the underlying string out of them before the <code class="language-plaintext highlighter-rouge">%v</code> fallback:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="n">v</span><span class="o">.</span><span class="n">Reflect</span><span class="p">()</span><span class="o">.</span><span class="n">CanInterface</span><span class="p">()</span><span class="o">:</span>
	<span class="k">switch</span> <span class="n">raw</span> <span class="o">:=</span> <span class="n">v</span><span class="o">.</span><span class="n">Reflect</span><span class="p">()</span><span class="o">.</span><span class="n">Interface</span><span class="p">()</span><span class="o">.</span><span class="p">(</span><span class="k">type</span><span class="p">)</span> <span class="p">{</span>
	<span class="k">case</span> <span class="n">alloytypes</span><span class="o">.</span><span class="n">Secret</span><span class="o">:</span>
		<span class="n">strValue</span> <span class="o">=</span> <span class="kt">string</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span>
	<span class="k">case</span> <span class="n">alloytypes</span><span class="o">.</span><span class="n">OptionalSecret</span><span class="o">:</span>
		<span class="n">strValue</span> <span class="o">=</span> <span class="n">raw</span><span class="o">.</span><span class="n">Value</span>
	<span class="k">default</span><span class="o">:</span>
		<span class="n">strValue</span> <span class="o">=</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"%v"</span><span class="p">,</span> <span class="n">raw</span><span class="p">)</span>
	<span class="p">}</span>
</code></pre></div></div>

<p>I thought about doing this generically — ask any capsule to convert itself to a string — but <code class="language-plaintext highlighter-rouge">OptionalSecret</code> deliberately refuses that conversion when it actually holds a secret, so a generic path would either drop the value or fall back to the struct again. Naming the two types keeps the behavior explicit, which is what you want in code that builds scrape targets.</p>

<p>One thing worth being honest about: when the value really is a secret, this now puts the real string into the target label. That is not a new leak. The old code already put that same string into the label — just wrapped in <code class="language-plaintext highlighter-rouge">{true ...}</code>. If you put a secret in a target, it ends up in the target either way; this change only stops mangling it. Putting a secret in a target address is arguably the wrong move, but that is a separate conversation from “the value should not be a struct literal.”</p>

<h2 id="proving-it-before-trusting-it">Proving it before trusting it</h2>

<p>The part I never skip: I wrote the test, then reverted the fix to watch it fail. With the fix gone the test produces <code class="language-plaintext highlighter-rouge">{true 10.0.0.1:9090}</code> — the exact string from the bug report — and with the fix back it produces <code class="language-plaintext highlighter-rouge">10.0.0.1:9090</code>. A test that has never been seen to fail is not a regression guard, it is decoration. Three cases cover a non-secret optional, a secret optional, and a plain <code class="language-plaintext highlighter-rouge">Secret</code>, all run through Alloy’s real evaluator so it exercises the same path the config does.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p><code class="language-plaintext highlighter-rouge">fmt.Sprintf("%v", x)</code> is a fine last resort and a bad default. The moment <code class="language-plaintext highlighter-rouge">x</code> can be a struct that wraps the thing you actually wanted, <code class="language-plaintext highlighter-rouge">%v</code> will happily hand you the wrapper. When a value passes through a type system that boxes things — secrets, optionals, capsules — the conversion back out is a real step, not something to leave to default formatting. The change is in <a href="https://github.com/grafana/alloy/pull/6605">grafana/alloy#6605</a>.</p>]]></content><author><name>Miguel Santos</name></author><category term="alloy" /><summary type="html"><![CDATA[Someone reported an Alloy config where they read a value out of a file and used it as a scrape target address. Instead of the address, the target came out as {true 10.0.0.1:9090} — the IP wrapped in what is unmistakably a Go struct printed with %v. The workaround they found was to wrap the value in convert.nonsensitive(), which is a strong hint about where to look. This is the kind of bug I like: the symptom names the cause if you know the codebase, and the fix is a few lines once you see it.]]></summary></entry><entry><title type="html">Argo CD could not lock the ref, so it gave up</title><link href="https://churi12.github.io/2026/06/argo-cd-could-not-lock-the-ref-and-gave-up/" rel="alternate" type="text/html" title="Argo CD could not lock the ref, so it gave up" /><published>2026-06-27T00:00:00+00:00</published><updated>2026-06-27T00:00:00+00:00</updated><id>https://churi12.github.io/2026/06/argo-cd-could-not-lock-the-ref-and-gave-up</id><content type="html" xml:base="https://churi12.github.io/2026/06/argo-cd-could-not-lock-the-ref-and-gave-up/"><![CDATA[<p>A maintainer filed an Argo CD bug that caught my eye because it lived right at the seam between a distributed system and a plain old git command. The source hydrator was failing with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> ! [remote rejected]     refs/notes/hydrator.metadata -&gt; refs/notes/hydrator.metadata (cannot lock ref 'refs/notes/hydrator.metadata': is at 4771346... but expected 30b2c90...)
error: failed to push some refs to '...'
</code></pre></div></div>

<p>To reproduce it you run two apps pointing at different destinations, handled by different application controller shards. Two shards, both hydrating, both pushing git notes to the same ref. That is a race, and git tells you so. The interesting part is not that the race happens, it is what the code did when it lost.</p>

<h2 id="notes-pushes-already-had-a-retry">Notes pushes already had a retry</h2>

<p>The hydrator stores metadata as git notes under <code class="language-plaintext highlighter-rouge">refs/notes/hydrator.metadata</code>, and <code class="language-plaintext highlighter-rouge">AddAndPushNote</code> in the git client already knows these pushes can collide. It runs the push inside a retry loop: fetch the latest notes, add the note locally, push, and if the push fails for a reason that looks like a concurrent update, fetch and try again with exponential backoff.</p>

<p>So someone had already thought about the race. The question was why this particular failure was not being retried.</p>

<h2 id="the-retry-only-recognised-four-phrasings">The retry only recognised four phrasings</h2>

<p>The loop decides whether to retry by string-matching the error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">errStr</span> <span class="o">:=</span> <span class="n">err</span><span class="o">.</span><span class="n">Error</span><span class="p">()</span>
<span class="n">isRetryable</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"fetch first"</span><span class="p">)</span> <span class="o">||</span>
    <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"reference already exists"</span><span class="p">)</span> <span class="o">||</span>
    <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"incorrect old value"</span><span class="p">)</span> <span class="o">||</span>
    <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"failed to update ref"</span><span class="p">)</span>

<span class="k">if</span> <span class="o">!</span><span class="n">isRetryable</span> <span class="p">{</span>
    <span class="k">return</span> <span class="k">struct</span><span class="p">{}{},</span> <span class="n">backoff</span><span class="o">.</span><span class="n">Permanent</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Four substrings, each a real way git can report a ref that moved under you. But the message from the bug report does not contain any of them. The concurrent push held the server-side lock, so git said <code class="language-plaintext highlighter-rouge">cannot lock ref</code>, which is a fifth phrasing nobody had added. The check returned false, the error got wrapped in <code class="language-plaintext highlighter-rouge">backoff.Permanent</code>, and the retry loop it was sitting inside refused to run. A failure the loop was built to absorb fell straight through it.</p>

<p>This is the trap with classifying errors by substring: the list is only as complete as the failures you have seen. Add a server that phrases the collision differently and the safety net has a hole in it, in exactly the spot it was meant to cover.</p>

<h2 id="the-fix-is-one-more-phrasing-and-not-three">The fix is one more phrasing, and not three</h2>

<p>I pulled the check into a small helper and added <code class="language-plaintext highlighter-rouge">cannot lock ref</code> to the set:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">isRetryableNotePushError</span><span class="p">(</span><span class="n">errStr</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"fetch first"</span><span class="p">)</span> <span class="o">||</span>
        <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"reference already exists"</span><span class="p">)</span> <span class="o">||</span>
        <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"incorrect old value"</span><span class="p">)</span> <span class="o">||</span>
        <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"failed to update ref"</span><span class="p">)</span> <span class="o">||</span>
        <span class="n">strings</span><span class="o">.</span><span class="n">Contains</span><span class="p">(</span><span class="n">errStr</span><span class="p">,</span> <span class="s">"cannot lock ref"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The tempting move is to also catch <code class="language-plaintext highlighter-rouge">remote rejected</code> and <code class="language-plaintext highlighter-rouge">failed to push some refs</code>, because both show up in that same error block. I left them out on purpose. Those two are generic wrappers: git prints them for protected branches, pre-receive hook denials, and other things that will never succeed no matter how many times you retry. Retrying on them would burn the whole backoff window on pushes that are permanently dead, and worse, it would hide the real reason behind a timeout. <code class="language-plaintext highlighter-rouge">cannot lock ref</code> is the specific signal that another push is holding the lock right now, which is the one case where trying again is the correct thing to do. Matching the precision of the four entries already there mattered more than catching every line in the message.</p>

<h2 id="proving-it-before-trusting-it">Proving it before trusting it</h2>

<p>The classifier is pure string logic, so it is cheap to test directly and there is no excuse not to. I wrote a table-driven test with all four existing signals, a non-retryable auth error that must stay false, and the real <code class="language-plaintext highlighter-rouge">cannot lock ref</code> block copied from the issue. Then I did the step I never skip: I removed the new line and ran the test. The <code class="language-plaintext highlighter-rouge">cannot lock ref</code> case failed, returning false where it should return true, which is the bug exactly as reported. Put the line back and it passes. A test you have never watched fail is not guarding anything.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>Retry logic that classifies failures by matching error strings is really a list of the failures someone happened to know about. It looks complete until a different git version or a different server phrases the same race a new way, and then the net has a hole precisely where you needed it. When you add an entry, it is worth asking the opposite question too: which lines look retryable but are actually permanent, so you do not paper over real errors while fixing the transient one. The change is in <a href="https://github.com/argoproj/argo-cd/pull/28469">argoproj/argo-cd#28469</a>, now merged.</p>]]></content><author><name>Miguel Santos</name></author><category term="argo-cd" /><summary type="html"><![CDATA[A maintainer filed an Argo CD bug that caught my eye because it lived right at the seam between a distributed system and a plain old git command. The source hydrator was failing with:]]></summary></entry></feed>