<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Roman Danko — Writing</title><link>https://blog.romandanko.sk/</link><description>Notes from operating platform infrastructure: Kubernetes, observability, incidents.</description><language>en</language><lastBuildDate>Sat, 03 Oct 2026 14:55:00 +0000</lastBuildDate><atom:link href="https://blog.romandanko.sk/" rel="self" type="application/rss+xml"/><item><title>When --check deletes for real</title><link>https://blog.romandanko.sk/posts/ansible-check-mode-deletes-for-real/</link><guid>https://blog.romandanko.sk/posts/ansible-check-mode-deletes-for-real/</guid><pubDate>Sat, 03 Oct 2026 14:55:00 +0000</pubDate><description>An Ansible check-mode rehearsal deleted a Kubernetes Job. The API server ignored the dry-run flag because the request also had a body, and the rehearsal ran a different collection version than production.</description><content:encoded><![CDATA[<p>I was adding a cleanup task to a playbook: delete leftover Kubernetes Jobs that
belong to nothing. Before letting it loose on the cluster I did what Ansible
teaches you to do and ran it with <code>--check</code>. Check mode is the rehearsal; it
reports what would change and changes nothing.</p>
<p>The task reported one Job it would delete. Then I looked at the cluster, and
the Job was gone.</p>
<p>It was a Job I meant to delete anyway, so the damage was zero. The question
was not. If <code>--check</code> can delete things, every rehearsal I had run against
production was a gamble.</p>
<h2 id="what-check-mode-actually-promises">What check mode actually promises</h2><p>Ansible itself does nothing in check mode. It sets a flag and each module
decides what that means. For most modules that is &ldquo;compute the change, skip
the write&rdquo;. The <code>kubernetes.core.k8s</code> module does something better: it sends
the real request to the Kubernetes API with
<a href="https://kubernetes.io/docs/reference/using-api/api-concepts/#dry-run">server-side dry run</a>.
The API server runs admission, validation and defaulting, and returns what
would happen, without persisting anything.</p>
<p>For a delete, the dry run is a query parameter:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">DELETE /apis/batch/v1/namespaces/kube-system/jobs/helm-delete-vault?dryRun=All
</span></span></code></pre></div><p>My task had one more line, because a Job should take its pods with it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Delete orphaned helm-controller Jobs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">kubernetes.core.k8s</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">state</span><span class="p">:</span><span class="w"> </span><span class="l">absent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Job</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">kube-system</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;{{ item }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">delete_options</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">propagationPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">Background</span><span class="w">
</span></span></span></code></pre></div><p><code>delete_options</code> travels as a <code>DeleteOptions</code> object in the request body. So
the request that went out was the URL above plus this body:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;apiVersion&#34;</span><span class="p">:</span> <span class="s2">&#34;v1&#34;</span><span class="p">,</span> <span class="nt">&#34;kind&#34;</span><span class="p">:</span> <span class="s2">&#34;DeleteOptions&#34;</span><span class="p">,</span> <span class="nt">&#34;propagationPolicy&#34;</span><span class="p">:</span> <span class="s2">&#34;Background&#34;</span><span class="p">}</span>
</span></span></code></pre></div><h2 id="the-api-server-reads-one-or-the-other">The API server reads one or the other</h2><p>The DELETE handler in the Kubernetes API server
(<a href="https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/delete.go"><code>handlers/delete.go</code></a>)
gets its <code>DeleteOptions</code> from exactly one place. In outline:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="k">if</span><span class="w"> </span><span class="nb">len</span><span class="p">(</span><span class="nx">body</span><span class="p">)</span><span class="w"> </span><span class="p">&gt;</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// decode DeleteOptions from the body</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w"> </span><span class="k">else</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// decode DeleteOptions from the URL query parameters</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p><code>dryRun</code> is a field of <code>DeleteOptions</code>. With a body present, the query string
is never read, <code>dryRun=All</code> is ignored, and the body says nothing about a dry
run. The server did exactly what it was asked: a real delete with background
propagation.</p>
<p>Neither side is wrong on its own. The API accepts the options in either place;
the client put half of them in each.</p>
<h2 id="a-bug-that-was-already-fixed">A bug that was already fixed</h2><p>The module&rsquo;s side is a known issue:
<a href="https://github.com/ansible-collections/kubernetes.core/issues/892">kubernetes.core#892</a>,
&ldquo;<code>kubernetes.core.k8s</code> deletes objects in check mode if <code>delete_options</code> are
set&rdquo;, reported in March 2025. Release 5.3.0 fixed it by leaving the body out in
check mode. Every 5.x release before it, and everything older, sends the body.</p>
<p>My infrastructure repository pins kubernetes.core 6.5.0, and CI installs
exactly that. In CI, the same task in check mode would have been a real dry
run.</p>
<p>But I had not rehearsed in CI. I had run the task locally, from a scratch
directory, in a Python environment with the <code>ansible</code> 9.10 package. That
package bundles its own copy of every collection. CI installs the pinned
versions into a collection path that takes precedence; my scratch directory had
no <code>ansible.cfg</code> pointing there, so Ansible quietly used the bundled copy:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> ansible-galaxy collection list kubernetes.core
</span></span><span class="line"><span class="cl"><span class="go">kubernetes.core 2.4.2
</span></span></span></code></pre></div><p>The rehearsal was faithful to a stack I do not deploy with.</p>
<h2 id="the-fix">The fix</h2><p>The task now puts the dry run in the body as well, which is correct for every
version of the collection. Old versions send the body and the server honours
<code>dryRun</code> from it; new versions drop the body in check mode and the server
reads <code>dryRun</code> from the query.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">    </span><span class="nt">delete_options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      {{ {&#39;propagationPolicy&#39;: &#39;Background&#39;}
</span></span></span><span class="line"><span class="cl"><span class="sd">         | combine({&#39;dryRun&#39;: [&#39;All&#39;]} if ansible_check_mode else {}) }}</span><span class="w">
</span></span></span></code></pre></div><p>The more important fixes are about the environment, not the task:</p>
<ul>
<li><strong>Rehearse with the versions you deploy with.</strong> The project pins its
collections; a local run that cannot find them should fail, not fall back to
whatever the <code>ansible</code> package bundles. That silent fallback is the real bug
here.</li>
<li><strong>Print the versions.</strong> <code>ansible --version</code> and
<code>ansible-galaxy collection list</code> at the start of a rehearsal cost nothing and
would have shown 2.4.2 next to the 6.5.0 in <code>requirements.yml</code>.</li>
<li><strong>Do not rehearse destructive tasks against production.</strong> A delete path
deserves a disposable cluster (kind, k3d) with a fixture object, where a
&ldquo;dry run&rdquo; that is not dry costs nothing. Check mode against production is for
tasks that create and update.</li>
</ul>
<h2 id="check-your-playbooks">Check your playbooks</h2><p>Any <code>kubernetes.core.k8s</code> task with <code>state: absent</code> and <code>delete_options</code> is
affected on collection versions before 5.3.0:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Which kubernetes.core will your ansible actually use?</span>
</span></span><span class="line"><span class="cl">ansible-galaxy collection list kubernetes.core
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Delete tasks with options: these are the ones to look at.</span>
</span></span><span class="line"><span class="cl">grep -rn -B8 <span class="s1">&#39;delete_options&#39;</span> --include<span class="o">=</span><span class="s1">&#39;*.yml&#39;</span> . <span class="p">|</span> grep -E <span class="s1">&#39;state: absent|delete_options&#39;</span>
</span></span></code></pre></div><p>If the version is older than 5.3.0, upgrade, or put <code>dryRun: ['All']</code> into
<code>delete_options</code> when <code>ansible_check_mode</code> is true.</p>
<h2 id="lessons">Lessons</h2><ul>
<li>Check mode is a promise each module makes for itself, not one Ansible makes.
Read how the module implements it before you trust it with deletes.</li>
<li>An API that accepts options in two places will eventually receive them in
both. Know which one wins.</li>
<li>A rehearsal is only as good as its resemblance to the real run. Same
versions, same collections, same flags.</li>
<li>The cheapest dry run is one where being wrong deletes nothing that matters.</li>
</ul>
]]></content:encoded></item><item><title>Three silent uninstalls: what k3s does to your HelmCharts on every patch upgrade</title><link>https://blog.romandanko.sk/posts/k3s-helm-controller-silent-uninstalls/</link><guid>https://blog.romandanko.sk/posts/k3s-helm-controller-silent-uninstalls/</guid><pubDate>Sat, 03 Oct 2026 00:00:00 +0000</pubDate><description>Four incidents in four months had one cause: every k3s patch upgrade re-runs every HelmChart, and the default failure policy turns a failed upgrade into an uninstall within seconds.</description><content:encoded><![CDATA[<p>Between June and October 2026 my k3s cluster quietly upgraded its storage
operator, lost eight days of logs, lost its pod networking, and lost its
monitoring for about fifteen hours. Nobody ran <code>helm</code>. Nobody merged anything
risky. Each time, the cluster did it to itself, a few minutes after a routine
Kubernetes patch upgrade.</p>
<p>It took four incidents before I saw that they were one incident. This post is
the mechanism, the trap I stepped into while cleaning up, and the changes that
make it boring again.</p>
<h2 id="how-k3s-installs-helm-charts">How k3s installs Helm charts</h2><p>k3s ships a small controller, the
<a href="https://github.com/k3s-io/helm-controller">helm-controller</a>, that turns a
<code>HelmChart</code> custom resource into a Helm release. You drop a manifest into
<code>/var/lib/rancher/k3s/server/manifests/</code>, k3s applies it as an <code>Addon</code>, and the
helm-controller creates a Job that runs <code>helm upgrade --install</code> inside a
<code>klipper-helm</code> container:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">helm.cattle.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">HelmChart</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">loki</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">kube-system</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">repo</span><span class="p">:</span><span class="w"> </span><span class="l">https://grafana.github.io/helm-charts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">chart</span><span class="p">:</span><span class="w"> </span><span class="l">loki</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="m">7.3.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">targetNamespace</span><span class="p">:</span><span class="w"> </span><span class="l">monitoring</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valuesContent</span><span class="p">:</span><span class="w"> </span><span class="p">|-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">    deploymentMode: SingleBinary</span><span class="w">
</span></span></span></code></pre></div><p>It is convenient for bootstrap: networking and storage are installed before
anything else in the cluster can run. Three properties matter later.</p>
<ul>
<li>A <code>HelmChart</code> is state in etcd, not a file. Deleting its YAML file from the
manifests directory leaves the <code>Addon</code> and the <code>HelmChart</code> in place, and
rebuilding a server node keeps them, because etcd survives.</li>
<li>The install Job runs once, but it is not the end of the story. The
helm-controller keeps comparing the Job it would create today with the one
that exists. The Job&rsquo;s definition includes the HelmChart&rsquo;s values and the
<code>klipper-helm</code> image, which is tied to the k3s version. When anything in it
differs, the controller deletes the finished Job and creates a new one, and
the new Job runs <code>helm upgrade</code> again.</li>
<li><code>spec.failurePolicy</code> defaults to <code>reinstall</code>.</li>
</ul>
<h2 id="the-mechanism">The mechanism</h2><p>
<figure>
  <picture>
    <source media="(max-width: 539px)" srcset="https://blog.romandanko.sk/posts/k3s-helm-controller-silent-uninstalls/mechanism-narrow.svg">
  <img src="https://blog.romandanko.sk/posts/k3s-helm-controller-silent-uninstalls/mechanism.svg" alt="The trigger, the fuel and the amplifier" loading="lazy">
  </picture><figcaption>Each incident needed all three.</figcaption>
</figure></p>
<p><strong>The trigger.</strong> My servers follow the k3s <code>v1.35</code> channel through the
system-upgrade-controller, so patch releases arrive unattended. Each one ships
a new <code>klipper-helm</code> image. The helm-controller now wants a different install
Job for every HelmChart, replaces each finished Job with a new one, and every
chart in the cluster is upgraded again, with nobody touching it. The three events in my logs are 26 June, 5 August
and 1 October, each followed within minutes by a burst of <code>helm-install-*</code>
pods.</p>
<p><strong>The fuel.</strong> A re-run is harmless when the HelmChart matches what you deploy.
Mine did not always:</p>
<ul>
<li>HelmCharts left behind after I moved kube-prometheus-stack and Loki to
Ansible-managed Helm releases. They still pointed at chart versions from 2024.</li>
<li>Rook HelmCharts without a <code>version</code>, so the Job installed whatever was newest.</li>
<li>Values that only worked as a three-way patch, and CRDs whose fields a second
manager also wrote.</li>
</ul>
<p><strong>The amplifier.</strong> With <code>failurePolicy: reinstall</code>, a failed upgrade leaves
the release in <code>failed</code>. The Job&rsquo;s own retry, seconds later, finds a failed
release and uninstalls it before installing again. The release did not need to
be broken beforehand; any upgrade failure is enough. From the Calico CRD Job:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">21:15:53  Error: UPGRADE FAILED: conflict occurred while applying object
</span></span><span class="line"><span class="cl">          /bgpconfigurations.crd.projectcalico.org ...
</span></span><span class="line"><span class="cl">          Apply failed with 1 conflict: conflict with &#34;OpenAPI-Generator&#34; ... .spec.versions
</span></span><span class="line"><span class="cl">21:16:06  Uninstalling failed helm chart
</span></span></code></pre></div><p>Thirteen seconds from a field-ownership conflict to deleting every Calico CRD.</p>
<h2 id="four-incidents-one-cause">Four incidents, one cause</h2><div class="table">
<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>What re-ran</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>26 June</td>
      <td>Rook HelmCharts without a pinned version</td>
      <td>Rook upgraded 1.19 → 1.20 unattended, without the separate CSI driver release 1.20 needs</td>
    </tr>
    <tr>
      <td>5–13 August</td>
      <td>Stale kube-prometheus-stack and loki-stack HelmCharts from 2024</td>
      <td>Loki 3 replaced by loki-stack; 8 days of logs lost. Nobody noticed; the next routine deploy healed it</td>
    </tr>
    <tr>
      <td>26 September</td>
      <td>Calico CRD chart, triggered by my own upgrade</td>
      <td>Field-ownership conflict, then uninstall: CRDs gone, pod networking and cluster DNS down</td>
    </tr>
    <tr>
      <td>1–2 October</td>
      <td>The same stale monitoring HelmCharts</td>
      <td>Prometheus downgraded to the 2024 chart, Loki replaced again; then my cleanup made it worse</td>
    </tr>
  </tbody>
</table>
</div><p>The August one hurts most in hindsight. The logging stack was swapped for an
older one for eight days and nothing alerted, because the thing that should
have told me was the thing being replaced.</p>
<h2 id="the-cleanup-that-uninstalled-my-monitoring">The cleanup that uninstalled my monitoring</h2><p>On 1 October I knew the stale HelmCharts were the problem, so I wrote a
playbook to remove them. A <code>helm-delete</code> Job runs on deletion because of a
finalizer, so the plan was: remove the finalizers, then delete the objects.</p>
<p>The helm-controller put the finalizer back before the delete arrived, ran its
<code>helm-delete</code> Jobs, and uninstalled the very releases I was trying to protect.
Prometheus, Alertmanager and Loki were gone.</p>
<p>The controller has an explicit opt-out that I had not read about. In
helm-controller v0.17, <code>shouldManage</code> skips any HelmChart with the
<code>helmcharts.helm.cattle.io/unmanaged</code> annotation. The safe order to retire a
HelmChart whose release you want to keep:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Remove the manifest file, so nothing re-applies the object.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Tell the controller to stop acting on it.</span>
</span></span><span class="line"><span class="cl">kubectl -n kube-system annotate helmchart loki helmcharts.helm.cattle.io/unmanaged<span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Now the finalizer can go, and the object with it.</span>
</span></span><span class="line"><span class="cl">kubectl -n kube-system patch helmchart loki --type<span class="o">=</span>merge -p <span class="s1">&#39;{&#34;metadata&#34;:{&#34;finalizers&#34;:[]}}&#39;</span>
</span></span><span class="line"><span class="cl">kubectl -n kube-system delete helmchart loki
</span></span></code></pre></div><p>The lesson I keep relearning: when you change a controller&rsquo;s objects, read how
the controller reacts in its source, not in your head.</p>
<h2 id="values-that-only-work-as-a-patch">Values that only work as a patch</h2><p>Getting Prometheus back took another surprise. Its values had worked for every
deploy for years, but every one of those deploys had been an upgrade: Helm
sends a three-way patch, and in a patch <code>null</code> means &ldquo;delete this field&rdquo;. A
fresh install creates the objects instead, and creation rejects the same values.</p>
<div class="table">
<table>
  <thead>
    <tr>
      <th>Value</th>
      <th>Meant</th>
      <th>On a fresh install</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>limits.cpu: null</code></td>
      <td>remove the chart&rsquo;s CPU limit</td>
      <td>a CPU limit of <code>0</code>: &ldquo;request must be less than or equal to limit&rdquo;</td>
    </tr>
    <tr>
      <td><code>matchLabels: null</code></td>
      <td>select everything</td>
      <td>invalid: <code>matchLabels</code> must be an object</td>
    </tr>
    <tr>
      <td><code>severity: 3</code></td>
      <td>alert label</td>
      <td>invalid: label values are strings</td>
    </tr>
  </tbody>
</table>
</div><p>The <code>klipper-helm</code> Job installs with <code>--server-side=true</code>, and fresh installs
with Helm 4 use server-side apply too, so every reinstall applies this
strictness. A chart that cannot be installed from scratch is one bad upgrade
away from an outage.</p>
<h2 id="what-changed">What changed</h2><ul>
<li><strong><code>failurePolicy: abort</code> on every HelmChart.</strong> A failed upgrade now stays
failed and waits for a human. A test in the repository fails the build if a
HelmChart template lacks it.</li>
<li><strong>Stale HelmCharts and Addons removed</strong>, with the unmanaged annotation first,
thirteen Addons in total, including ones from install methods I had
abandoned years ago.</li>
<li><strong>Every chart version pinned.</strong> Renovate proposes upgrades; nothing floats.</li>
<li><strong>Values valid for creation.</strong> A test rejects <code>null</code> and numeric label values
in the inventory. Before an upgrade I render each chart with its live
version and values and run <code>kubectl apply --server-side --dry-run=server</code> on
the result under temporary names.</li>
<li><strong>A read-only drift check</strong> that fails when a HelmChart has no template in
the repository, lacks <code>abort</code>, or a Helm release&rsquo;s latest revision is not
<code>deployed</code>. Its first run found nothing, which is the point: it runs before
the next patch upgrade does.</li>
<li><strong>One owner per object.</strong> The Calico operator no longer writes its own CRDs;
the CRD chart does.</li>
</ul>
<p>Next is moving the remaining HelmCharts that bootstrap does not need into
Ansible-managed releases, so the controller that upgrades k3s stops
redeploying applications.</p>
<h2 id="check-your-cluster">Check your cluster</h2><p>If you run k3s or RKE2 with HelmCharts, two commands show most of the risk:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Every HelmChart with its pinned version and failure policy.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># An empty POLICY means reinstall.</span>
</span></span><span class="line"><span class="cl">kubectl get helmcharts -A -o custom-columns<span class="o">=</span><span class="se">\
</span></span></span><span class="line"><span class="cl">NS:.metadata.namespace,NAME:.metadata.name,CHART:.spec.chart,<span class="se">\
</span></span></span><span class="line"><span class="cl">VERSION:.spec.version,POLICY:.spec.failurePolicy
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Releases whose latest revision is not deployed.</span>
</span></span><span class="line"><span class="cl">helm list -A --failed --pending
</span></span></code></pre></div><p>An empty <code>VERSION</code> means the next re-run installs whatever is newest. An empty
<code>POLICY</code> means the next failed upgrade is an uninstall. A HelmChart that no
file in your repository describes is the stale kind.</p>
<h2 id="lessons">Lessons</h2><ul>
<li>Unattended upgrades re-run everything. Anything stale surfaces there, at a
time you did not choose.</li>
<li><code>reinstall</code> is not a retry. For releases that own data or APIs, only
<code>abort</code> is safe.</li>
<li>A manifest directory is not the source of truth; etcd is. Retiring a file
needs a cleanup step.</li>
<li>&ldquo;It deploys fine&rdquo; may only mean &ldquo;it upgrades fine&rdquo;. Test fresh installs.</li>
<li>An outage that nobody notices for eight days is a monitoring bug, not luck.</li>
</ul>
]]></content:encoded></item></channel></rss>