<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://sysadminas.eu/feed.xml" rel="self" type="application/atom+xml" /><link href="http://sysadminas.eu/" rel="alternate" type="text/html" /><updated>2026-05-21T04:43:24+00:00</updated><id>http://sysadminas.eu/feed.xml</id><title type="html">sysadminas.eu</title><subtitle>Sharing knowledge based on my Cloud Engineer journey</subtitle><author><name>Andrej Trusevic</name></author><entry><title type="html">How AI Agents Changed My Helm Chart Upgrade Routine</title><link href="http://sysadminas.eu/AI-Agents-and-Helm-Chart-Upgrades/" rel="alternate" type="text/html" title="How AI Agents Changed My Helm Chart Upgrade Routine" /><published>2026-05-19T00:00:00+00:00</published><updated>2026-05-19T00:00:00+00:00</updated><id>http://sysadminas.eu/AI-Agents-and-Helm-Chart-Upgrades</id><content type="html" xml:base="http://sysadminas.eu/AI-Agents-and-Helm-Chart-Upgrades/"><![CDATA[<p><img align="right" width="400" height="300" src="../assets/images/post30/1.png" /></p>

<p>Upgrading a Helm chart sounds simple until you are three environments deep, maintaining a vendored copy of a 2000-line <code class="language-plaintext highlighter-rouge">values.yaml</code>, and trying to remember whether the image tag format uses a <code class="language-plaintext highlighter-rouge">v</code> prefix or not. I have been doing chart upgrades like this for years. They are never truly hard, but they are reliably fiddly, and “reliably fiddly” is exactly where human error lives.</p>

<p>Recently I wrote a Claude Code skill to handle this class of work. This post is about what that looks like in practice: the actual skill file, the plan the agent produced for a real upgrade, and the broader idea behind encoding operational knowledge as reusable AI instructions.</p>

<h2 id="the-problem-with-routine-upgrades">The Problem with Routine Upgrades</h2>

<p>Our Kubernetes monitoring setup uses <a href="https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack">kube-prometheus-stack</a> deployed via Flux CD across three environments: <code class="language-plaintext highlighter-rouge">experimental</code>, <code class="language-plaintext highlighter-rouge">dev</code>, and <code class="language-plaintext highlighter-rouge">prod</code>. Each environment has its own <code class="language-plaintext highlighter-rouge">values.yaml</code> that contains the full upstream chart values, not a sparse overlay, but the complete file with our ACR image overrides baked in. When a new chart version drops, the upgrade checklist looks something like this:</p>

<ol>
  <li>Pull the new upstream chart and replace the local directory</li>
  <li>Check whether CRDs changed (if so, handle migration before the upgrade lands)</li>
  <li>Diff the upstream <code class="language-plaintext highlighter-rouge">values.yaml</code> — find every new key, every removed key, every image tag that changed</li>
  <li>Apply those structural changes to all three environment values files, preserving our ACR paths and custom settings</li>
  <li>Update the three <code class="language-plaintext highlighter-rouge">containerImagesToMirror</code> blocks in <code class="language-plaintext highlighter-rouge">azure-pipelines.yml</code> (one per environment — miss one and prod runs a stale image). Pipeline mirrors images from upstream to our ACR, so the tags there must match the env values files.</li>
  <li>Update the <code class="language-plaintext highlighter-rouge">README.md</code> shields.io badges</li>
  <li>Add a <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> entry</li>
  <li>Lint and template-render all three environments to verify nothing is broken</li>
  <li>Grep for stale version references</li>
  <li>Commit</li>
</ol>

<p>None of these steps are individually hard. But do them manually at 4pm on a Friday and you will probably miss something: a tag update in one pipeline block, a new upstream schema key that needs propagating, an old version reference hiding in a comment. I have seen all of these before.</p>

<h2 id="the-skill">The Skill</h2>

<p>Claude Code supports custom skills, markdown files that describe a workflow and live under <code class="language-plaintext highlighter-rouge">.claude/skills/</code>. When you invoke a skill, the agent follows the instructions in the file as if they were part of its operating context.</p>

<p>The key insight is that a skill is not a script. It is not a Bash file that blindly executes a sequence of commands. It is a set of instructions that the agent interprets, bringing its own judgment to ambiguous situations, like figuring out whether a new upstream key was intentionally omitted from an env file, or whether a changed image tag format means the <code class="language-plaintext highlighter-rouge">v</code> prefix convention changed.</p>

<p>Here is the actual <code class="language-plaintext highlighter-rouge">helm-chart-upgrade</code> skill file, unedited:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">helm-chart-upgrade</span>
<span class="na">description</span><span class="pi">:</span>
  <span class="s">Guides a full Helm chart upgrade for repositories that bundle an upstream</span>
  <span class="s">chart with environment values files and an Azure DevOps pipeline.</span>

<span class="c1"># Helm Chart Upgrade</span>

<span class="s">This skill guides a complete, safe Helm chart upgrade for repos that follow this pattern</span><span class="err">:</span>
  <span class="pi">-</span> <span class="s">A local chart directory wrapping an upstream Helm chart (e.g. `kyverno/`)</span>
  <span class="pi">-</span> <span class="s">Three environment values files (`environments/{experimental,dev,prod}/values.yaml`)</span>
    <span class="s">containing the **full** upstream chart values</span>
  <span class="pi">-</span> <span class="s">An Azure DevOps pipeline (`azure-pipelines.yml`) with `containerImagesToMirror`</span>
    <span class="s">lists per environment</span>
  <span class="pi">-</span> <span class="s">A `README.md` with shield.io version badges</span>
  <span class="pi">-</span> <span class="s">A `CHANGELOG.md`</span>
<span class="nn">---</span>

<span class="gu">## Step 0 — Collect inputs</span>

Before touching anything, confirm these five values. Extract from the user's message
first; only ask about what is missing.

| Input                  | Example           |
| ---------------------- | ----------------- |
| Local chart directory  | <span class="sb">`kyverno`</span>         |
| Helm repo + chart name | <span class="sb">`kyverno/kyverno`</span> |
| Old app version tag    | <span class="sb">`v1.17.1`</span>         |
| New app version tag    | <span class="sb">`v1.17.2`</span>         |
| New Helm chart version | <span class="sb">`3.7.2`</span>           |

The app version tag and Helm chart version often differ (e.g. app <span class="sb">`v1.17.2`</span>,
chart <span class="sb">`3.7.2`</span>). Confirm both.

<span class="gs">**`v` prefix consistency**</span> — before doing any version replacement, check the existing
<span class="sb">`containerImagesToMirror`</span> entries to determine whether images are tagged <span class="sb">`v1.2.3`</span> or
<span class="sb">`1.2.3`</span>. Match that format exactly in every replacement. Never assume the <span class="sb">`v`</span> prefix
is present or absent.
<span class="p">
---
</span>
<span class="gu">## Step 1 — Plan first, change nothing</span>

Enter plan mode. Before any file is touched:
<span class="p">
1.</span> Find every file referencing the old version:
   <span class="p">```</span><span class="nl">bash
</span>   <span class="nb">grep</span> <span class="nt">-r</span> <span class="s2">"&lt;old-version&gt;"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.yaml"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.yml"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.md"</span> <span class="nb">.</span> <span class="se">\</span>
     | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s2">".git/"</span>
   <span class="p">```</span>
<span class="p">2.</span> Write a checklist of all files and what changes each needs.
<span class="p">3.</span> Identify any potential breaking changes in the new chart version (schema changes,
   removed keys, etc.) and how they will be reconciled in env values files.
<span class="p">4.</span> Inform the user of the full scope of changes that will be made, including any manual
   steps they must take (e.g. applying CRDs if they changed).
<span class="p">5.</span> Get explicit user approval before proceeding.
<span class="p">
---
</span>
<span class="gu">## Step 1.5 — Read the upstream release notes</span>

Before touching any file, fetch the chart's changelog or GitHub release notes for the
version range being crossed. Even patch bumps occasionally include a "migration required"
note. Summarise any breaking changes (or note "none found") in the plan so the user is
informed before implementation begins.
<span class="p">
---
</span>
<span class="gu">## Step 2 — Pull the upstream chart</span>

<span class="p">```</span><span class="nl">bash
</span>helm repo update
helm pull &lt;helm-repo/chart&gt; <span class="nt">--version</span> &lt;new-chart-version&gt; <span class="nt">--untar</span> <span class="nt">--untardir</span> /tmp/chart-upgrade
<span class="p">```</span>

Replace the local chart directory:

<span class="p">```</span><span class="nl">bash
</span><span class="nb">rm</span> <span class="nt">-rf</span> ./&lt;chart-dir&gt;/
<span class="nb">cp</span> <span class="nt">-r</span> /tmp/chart-upgrade/&lt;chart-name&gt; ./&lt;chart-dir&gt;/
<span class="p">```</span>

Verify the pull succeeded before continuing.

<span class="gs">**CRD check**</span> — Helm does not upgrade CRDs by default. After pulling, diff the <span class="sb">`crds/`</span>
directory:

<span class="p">```</span><span class="nl">bash
</span>git diff HEAD &lt;chart-dir&gt;/crds/
<span class="p">```</span>

If CRDs changed, flag it to the user — they must apply CRDs manually with
<span class="sb">`kubectl apply -f &lt;chart-dir&gt;/crds/`</span> before the Helm release is upgraded, or the
cluster will have schema drift.
<span class="p">
---
</span>
<span class="gu">## Step 3 — Identify schema changes in values.yaml</span>

<span class="p">```</span><span class="nl">bash
</span>git diff HEAD &lt;chart-dir&gt;/values.yaml
<span class="p">```</span>

Focus on <span class="gs">**additions**</span> — new top-level keys or sections that were not present in the
old chart. These need to be propagated to the environment values files.

<span class="gs">**Also check for removed or renamed keys**</span> — a key that exists in env values files but
no longer exists in the new upstream <span class="sb">`values.yaml`</span> will be silently ignored by Helm,
masking misconfiguration. Identify removals explicitly:

<span class="p">```</span><span class="nl">bash
</span><span class="nb">comm</span> <span class="nt">-23</span> &lt;<span class="o">(</span><span class="nb">grep</span> <span class="nt">-E</span> <span class="s2">"^[a-z]"</span> environments/experimental/values.yaml | <span class="nb">sort</span><span class="o">)</span> <span class="se">\</span>
         &lt;<span class="o">(</span><span class="nb">grep</span> <span class="nt">-E</span> <span class="s2">"^[a-z]"</span> &lt;chart-dir&gt;/values.yaml | <span class="nb">sort</span><span class="o">)</span>
<span class="p">```</span>

Report any removed top-level keys to the user before proceeding.

<span class="gs">**Check sub-chart dependency versions**</span> — if <span class="sb">`&lt;chart-dir&gt;/Chart.yaml`</span> has a
<span class="sb">`dependencies:`</span> block, diff it:

<span class="p">```</span><span class="nl">bash
</span>git diff HEAD &lt;chart-dir&gt;/Chart.yaml
<span class="p">```</span>

Sub-charts (e.g. <span class="sb">`common`</span>, <span class="sb">`postgresql`</span>) may have their own image tags that appear in
<span class="sb">`containerImagesToMirror`</span> and need separate version bumps.
<span class="p">
---
</span>
<span class="gu">## Step 4 — Reconcile environment values files</span>

The env values files contain the <span class="gs">**complete upstream chart values**</span>, not a sparse
override layer. They must be kept in full structural sync with the upstream
<span class="sb">`&lt;chart-dir&gt;/values.yaml`</span> after every upgrade — including key additions, key removals,
comments, blank lines, and ordering. The only exception is the _value_ of any key that
has been deliberately customised.

<span class="gs">**Sync rules — apply in order:**</span>
<span class="p">
1.</span> <span class="gs">**Added keys**</span> — if a key/block exists in the new upstream <span class="sb">`values.yaml`</span> but not in
   the env file, insert it at the same relative position as upstream (between the same
   neighbouring keys). Use the upstream default value.
<span class="p">
2.</span> <span class="gs">**Removed keys**</span> — if a key/block no longer exists in the new upstream <span class="sb">`values.yaml`</span>
   but is still present in the env file, remove it from the env file. Exception: if the
   key is known to be a local-only addition (not derived from upstream), leave it in
   place and flag it to the user.
<span class="p">
3.</span> <span class="gs">**Preserve customised values**</span> — for keys that exist in both the env file and
   upstream, keep the env file's value unchanged if it differs from the upstream default
   (ACR image repository paths, resource limits, Azure Workload Identity annotations,
   replica counts, etc.). Do not overwrite customised values with upstream defaults.
<span class="p">
4.</span> <span class="gs">**Sync structure**</span> — comments, blank lines, ordering, and indentation should follow
   the upstream <span class="sb">`values.yaml`</span>. Do not invent or retain env-file-specific formatting that
   diverges from upstream.

<span class="gs">**Apply to all three environments**</span> — experimental, dev, and prod must all receive the
same structural reconciliation.

<span class="gs">**How to find the insertion point for new blocks:**</span>
<span class="p">
1.</span> In the new <span class="sb">`&lt;chart-dir&gt;/values.yaml`</span>, identify the key immediately _before_ and
   immediately _after_ the new section.
<span class="p">2.</span> Grep for the preceding key in each env values file to locate the exact insertion line.
<span class="p">3.</span> Insert the new block between those keys.

<span class="gs">**Images that inherit from appVersion**</span> — component images that have <span class="sb">`tag: ~`</span> or no
explicit tag set automatically pick up <span class="sb">`appVersion`</span> from <span class="sb">`Chart.yaml`</span>. Do not add
explicit version tags for them; leave them as-is.
<span class="p">
---
</span>
<span class="gu">## Step 5 — Update azure-pipelines.yml</span>

The pipeline has <span class="gs">**three separate `containerImagesToMirror` blocks**</span> — one each for
experimental, dev, and prod. All three must be updated. Missing even one leaves a stale
tag in production.

For each block: replace every image entry containing <span class="sb">`:&lt;old-version&gt;`</span> with
<span class="sb">`:&lt;new-version&gt;`</span>.

<span class="gs">**Do not change utility image versions**</span> (<span class="sb">`readiness-checker`</span>, <span class="sb">`kubectl`</span>, <span class="sb">`busybox`</span>,
<span class="sb">`curl`</span>, etc.) unless the user explicitly asks. These are pinned independently of the
chart release.

<span class="gs">**Re-read the file before each subsequent edit.**</span> A Prettier post-edit hook reformats
YAML files in place. If you edit twice without re-reading, your <span class="sb">`old_string`</span> won't match
the reformatted file and the edit will fail silently.
<span class="p">
---
</span>
<span class="gu">## Step 6 — Update README.md</span>

Find shield.io badge lines that reference the chart being upgraded and update the
version strings:
<span class="p">
-</span> App version badge: <span class="sb">`&lt;ChartName&gt;-v&lt;old&gt;`</span> → <span class="sb">`&lt;ChartName&gt;-v&lt;new&gt;`</span>
<span class="p">-</span> Helm chart version badge: <span class="sb">`&lt;ChartName&gt;_Helm_Chart-v&lt;old-chart&gt;`</span> →
  <span class="sb">`&lt;ChartName&gt;_Helm_Chart-v&lt;new-chart&gt;`</span>
<span class="p">-</span> Any inline version references in documentation text (e.g. example ACR mirror paths)

Re-read the file before each subsequent edit if Prettier is active.
<span class="p">
---
</span>
<span class="gu">## Step 7 — Update CHANGELOG.md</span>

Prepend a new dated entry at the top of the file (immediately after the file header):

<span class="p">```</span><span class="nl">markdown
</span><span class="gu">## YYYY-MM-DD — &lt;Chart Name&gt; v&lt;new-version&gt;</span>

<span class="gu">### Changed</span>
<span class="p">
-</span> Upgraded <span class="nt">&lt;Chart</span> <span class="na">Name</span><span class="nt">&gt;</span> to v<span class="nt">&lt;new-version&gt;</span> (Helm chart <span class="nt">&lt;new-chart-version&gt;</span>)
<span class="p">-</span> Updated all container image tags in <span class="sb">`azure-pipelines.yml`</span> to v<span class="nt">&lt;new-version&gt;</span>
  across all three environments
<span class="p">```</span>

If new schema sections were added to env values files, add a bullet for each:

<span class="p">```</span><span class="nl">markdown
</span><span class="p">-</span> New <span class="sb">`&lt;section-name&gt;`</span> section added to upstream chart; inserted into all environment
  values files with upstream defaults
<span class="p">```</span>
<span class="p">
---
</span>
<span class="gu">## Step 8 — Verify</span>

<span class="p">```</span><span class="nl">bash
</span><span class="c"># Lint the chart against every environment</span>
helm lint ./&lt;chart-dir&gt; <span class="nt">-f</span> environments/experimental/values.yaml
helm lint ./&lt;chart-dir&gt; <span class="nt">-f</span> environments/dev/values.yaml
helm lint ./&lt;chart-dir&gt; <span class="nt">-f</span> environments/prod/values.yaml
<span class="p">```</span>

<span class="sb">`helm lint`</span> validates structure but passes even with some broken references. Follow with
a full template render, which catches missing keys, bad references, and schema violations
that lint misses:

<span class="p">```</span><span class="nl">bash
</span>helm template &lt;release-name&gt; ./&lt;chart-dir&gt; <span class="nt">-f</span> environments/experimental/values.yaml <span class="o">&gt;</span> /dev/null
helm template &lt;release-name&gt; ./&lt;chart-dir&gt; <span class="nt">-f</span> environments/dev/values.yaml <span class="o">&gt;</span> /dev/null
helm template &lt;release-name&gt; ./&lt;chart-dir&gt; <span class="nt">-f</span> environments/prod/values.yaml <span class="o">&gt;</span> /dev/null
<span class="p">```</span>

<span class="p">```</span><span class="nl">bash
</span><span class="c"># Confirm no stale old-version references remain</span>
<span class="c"># (CHANGELOG historical entries are expected — exclude them)</span>
<span class="nb">grep</span> <span class="nt">-r</span> <span class="s2">"&lt;old-version&gt;"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.yaml"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.yml"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.md"</span> <span class="nb">.</span> <span class="se">\</span>
  | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s2">"CHANGELOG.md"</span> | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s2">".git/"</span>
<span class="p">```</span>

The grep must return empty output. Fix any remaining references before committing.
<span class="p">
---
</span>
<span class="gu">## Step 9 — Commit</span>

Use the <span class="sb">`gitflow`</span> skill to stage and commit all changed files atomically. Craft the
commit message to explain _why_ (the version bump) and _what_ changed (images, schema):

<span class="p">```</span><span class="nl">
</span>chore(&lt;chart-name&gt;): upgrade to v&lt;new-version&gt; (Helm chart &lt;new-chart-version&gt;)

Bump &lt;Chart Name&gt; from v&lt;old-version&gt; to v&lt;new-version&gt;.
- Updated containerImagesToMirror in azure-pipelines.yml across all three environments
- Reconciled new &lt;section-name&gt; schema section into all environment values files
<span class="p">```</span>

Omit sections that did not apply to this upgrade.
</code></pre></div></div>

<p>A few things worth noting about how this is written:</p>

<p><strong>It documents the traps inline.</strong> Step 5 explicitly warns about the Prettier post-edit hook and instructs the agent to re-read the file before each subsequent edit. That warning exists because I was burned by it once: the hook reformats YAML on every save, which means a second edit without re-reading produces an <code class="language-plaintext highlighter-rouge">old_string</code> that no longer matches the file. Writing it into the skill means it is handled correctly on every subsequent run.</p>

<p><strong>The invariant in Step 4 is the most important line in the file.</strong> “Never overwrite customised values with upstream defaults.” Without this constraint, the agent has no way to know which values are intentionally different from upstream and which are just stale. Stating it explicitly means it is applied consistently every time.</p>

<p><strong>Steps are numbered but the agent can reason across them.</strong> Step 1.5 exists because I added it after an upgrade where I missed a breaking change note that was buried in the release notes. It is out of numeric order but the agent reads it correctly in context.</p>

<p>A <code class="language-plaintext highlighter-rouge">gitflow</code> is a another custom skill I wrote to handle git operations with a structured commit message format. The <code class="language-plaintext highlighter-rouge">helm-chart-upgrade</code> skill calls it for the commit step, but it can also be invoked independently for other commits that follow the same format.</p>

<h2 id="the-plan-the-agent-produced">The Plan the Agent Produced</h2>

<p>When I invoked <code class="language-plaintext highlighter-rouge">/helm-chart-upgrade</code> for the kube-prometheus-stack 83.5.1 → 85.1.3 upgrade, the agent entered plan mode and produced the following plan before touching a single file. This is the actual output, not a summary:</p>

<hr />

<p><strong>Context</strong></p>

<p>Routine Helm chart version bump from <strong>83.5.1 → 85.1.3</strong>. The prometheus-operator appVersion stays at <strong>v0.90.1</strong>, so no CRD changes are required. However, two meaningful changes land in this range:</p>

<ol>
  <li><strong>Grafana jumps to v13</strong> (13.0.1-security-01) — a major version upgrade from the current 12.4.3.</li>
  <li><strong>Distroless images become the default</strong> for Prometheus (explicit <code class="language-plaintext highlighter-rouge">-distroless</code> tag) and Node Exporter (<code class="language-plaintext highlighter-rouge">distroless: true</code> flag in sub-chart values). These are lean, shell-free variants — they work fine in production but <code class="language-plaintext highlighter-rouge">kubectl exec</code> shell access into those pods will not be available.</li>
</ol>

<p><strong>Step 0 — Confirmed Inputs</strong></p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Local chart directory</td>
      <td><code class="language-plaintext highlighter-rouge">kube-prometheus-stack/</code></td>
    </tr>
    <tr>
      <td>Helm repo / chart</td>
      <td><code class="language-plaintext highlighter-rouge">prometheus-community/kube-prometheus-stack</code></td>
    </tr>
    <tr>
      <td>Old chart version</td>
      <td><code class="language-plaintext highlighter-rouge">83.5.1</code></td>
    </tr>
    <tr>
      <td>Old appVersion</td>
      <td><code class="language-plaintext highlighter-rouge">v0.90.1</code></td>
    </tr>
    <tr>
      <td>New chart version</td>
      <td><code class="language-plaintext highlighter-rouge">85.1.3</code></td>
    </tr>
    <tr>
      <td>New appVersion</td>
      <td><code class="language-plaintext highlighter-rouge">v0.90.1</code> (unchanged)</td>
    </tr>
  </tbody>
</table>

<p><strong>Release Notes Summary (83.5.1 → 85.1.3)</strong></p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Details</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CRD changes</td>
      <td><strong>None</strong> — appVersion stays v0.90.1; no <code class="language-plaintext highlighter-rouge">kubectl apply -f crds/</code> needed</td>
    </tr>
    <tr>
      <td>Breaking (84.x)</td>
      <td>Grafana v13 upgrade — check dashboard/plugin compatibility</td>
    </tr>
    <tr>
      <td>Breaking (85.x)</td>
      <td>Distroless images enabled by default for Prometheus &amp; Node Exporter</td>
    </tr>
    <tr>
      <td>Sub-chart bumps</td>
      <td>kube-state-metrics 7.2.2→7.3.0, prometheus-node-exporter 4.53.1→4.55.0, grafana 11.6.1→12.3.3</td>
    </tr>
  </tbody>
</table>

<p><strong>Image Changes (all 3 pipeline blocks + env values files)</strong></p>

<table>
  <thead>
    <tr>
      <th>Image</th>
      <th>Old tag</th>
      <th>New tag</th>
      <th>Change type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quay.io/prometheus/prometheus</code></td>
      <td><code class="language-plaintext highlighter-rouge">v3.11.2</code></td>
      <td><code class="language-plaintext highlighter-rouge">v3.11.3-distroless</code></td>
      <td>version + distroless</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quay.io/prometheus/alertmanager</code></td>
      <td><code class="language-plaintext highlighter-rouge">v0.32.0</code></td>
      <td><code class="language-plaintext highlighter-rouge">v0.32.1</code></td>
      <td>patch bump</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">docker.io/grafana/grafana</code></td>
      <td><code class="language-plaintext highlighter-rouge">12.4.3</code></td>
      <td><code class="language-plaintext highlighter-rouge">13.0.1-security-01</code></td>
      <td><strong>MAJOR</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quay.io/kiwigrid/k8s-sidecar</code></td>
      <td><code class="language-plaintext highlighter-rouge">2.6.0</code></td>
      <td><code class="language-plaintext highlighter-rouge">2.7.3</code></td>
      <td>minor bump</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ghcr.io/jkroepke/kube-webhook-certgen</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.8.1</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.8.2</code></td>
      <td>patch bump</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quay.io/prometheus/node-exporter</code></td>
      <td><code class="language-plaintext highlighter-rouge">v1.11.1</code></td>
      <td><code class="language-plaintext highlighter-rouge">v1.11.1-distroless</code></td>
      <td>distroless only</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quay.io/prometheus-operator/prometheus-operator</code></td>
      <td><code class="language-plaintext highlighter-rouge">v0.90.1</code></td>
      <td><code class="language-plaintext highlighter-rouge">v0.90.1</code></td>
      <td>no change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quay.io/prometheus-operator/prometheus-config-reloader</code></td>
      <td><code class="language-plaintext highlighter-rouge">v0.90.1</code></td>
      <td><code class="language-plaintext highlighter-rouge">v0.90.1</code></td>
      <td>no change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">registry.k8s.io/kube-state-metrics/kube-state-metrics</code></td>
      <td><code class="language-plaintext highlighter-rouge">v2.18.0</code></td>
      <td><code class="language-plaintext highlighter-rouge">v2.18.0</code></td>
      <td>no change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">docker.io/library/busybox</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.35</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.35</code></td>
      <td>no change</td>
    </tr>
  </tbody>
</table>

<p><strong>Files to Change</strong></p>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>What changes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">kube-prometheus-stack/</code></td>
      <td>Replace entire directory with pulled chart 85.1.3</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">environments/dev/values.yaml</code></td>
      <td>Sync new upstream structure; update image tags; add <code class="language-plaintext highlighter-rouge">distroless: true</code> node-exporter key; add kubelet cert expiration thresholds to defaultRules</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">environments/prod/values.yaml</code></td>
      <td>Same as dev (registry: <code class="language-plaintext highlighter-rouge">myacrprod.azurecr.io</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">environments/experimental/values.yaml</code></td>
      <td>Same as dev</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">azure-pipelines.yml</code></td>
      <td>Update 3× <code class="language-plaintext highlighter-rouge">containerImagesToMirror</code> blocks with new tags</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">README.md</code></td>
      <td>Badge: <code class="language-plaintext highlighter-rouge">83.5.1</code> → <code class="language-plaintext highlighter-rouge">85.1.3</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CHANGELOG.md</code></td>
      <td>Prepend dated entry for this upgrade</td>
    </tr>
  </tbody>
</table>

<p><strong>Execution Steps</strong></p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Helm repo update + pull chart 85.1.3</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Replace chart directory</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />CRD diff (expected: no changes since appVersion unchanged)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Values diff — identify all structural additions/removals</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Sync <code class="language-plaintext highlighter-rouge">environments/dev/values.yaml</code></li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Sync <code class="language-plaintext highlighter-rouge">environments/prod/values.yaml</code></li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Sync <code class="language-plaintext highlighter-rouge">environments/experimental/values.yaml</code></li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Update <code class="language-plaintext highlighter-rouge">azure-pipelines.yml</code> — replace image tags in all 3 <code class="language-plaintext highlighter-rouge">containerImagesToMirror</code> blocks</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Update <code class="language-plaintext highlighter-rouge">README.md</code> — chart badge <code class="language-plaintext highlighter-rouge">83.5.1</code> → <code class="language-plaintext highlighter-rouge">85.1.3</code></li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Update <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code> — prepend entry dated 2026-05-19</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Lint + template render all 3 environments</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Stale-reference check — grep must return empty</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Commit via <code class="language-plaintext highlighter-rouge">gitflow</code> skill</li>
</ul>

<p><strong>Verification</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Lint</span>
helm lint kube-prometheus-stack <span class="nt">-f</span> environments/experimental/values.yaml
helm lint kube-prometheus-stack <span class="nt">-f</span> environments/dev/values.yaml
helm lint kube-prometheus-stack <span class="nt">-f</span> environments/prod/values.yaml

<span class="c"># Full template render (catches missing keys and bad references)</span>
helm template kube-prometheus-stack kube-prometheus-stack <span class="se">\</span>
  <span class="nt">-f</span> environments/experimental/values.yaml <span class="o">&gt;</span> /dev/null
helm template kube-prometheus-stack kube-prometheus-stack <span class="se">\</span>
  <span class="nt">-f</span> environments/dev/values.yaml <span class="o">&gt;</span> /dev/null
helm template kube-prometheus-stack kube-prometheus-stack <span class="se">\</span>
  <span class="nt">-f</span> environments/prod/values.yaml <span class="o">&gt;</span> /dev/null

<span class="c"># No stale references (CHANGELOG historical entries expected — excluded)</span>
<span class="nb">grep</span> <span class="nt">-r</span> <span class="s2">"83</span><span class="se">\.</span><span class="s2">5</span><span class="se">\.</span><span class="s2">1"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.yaml"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.yml"</span> <span class="nt">--include</span><span class="o">=</span><span class="s2">"*.md"</span> <span class="nb">.</span> <span class="se">\</span>
  | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s2">"CHANGELOG.md"</span> | <span class="nb">grep</span> <span class="nt">-v</span> <span class="s2">".git/"</span>
</code></pre></div></div>

<p><strong>Manual Action Required Before Deployment</strong></p>

<blockquote>
  <p><strong>None</strong> — appVersion unchanged (v0.90.1), CRDs are identical. Upgrade can proceed via normal pipeline after this PR lands.</p>

  <p><strong>Grafana 13 note</strong> — test dashboard rendering and any custom plugins in <code class="language-plaintext highlighter-rouge">experimental</code> environment before promoting to <code class="language-plaintext highlighter-rouge">dev</code>/<code class="language-plaintext highlighter-rouge">prod</code>.</p>
</blockquote>

<hr />

<p>I approved the plan, and the agent executed every step. The commit landed clean on the first attempt.</p>

<h2 id="what-the-agent-caught">What the Agent Caught</h2>

<p>Two things in this upgrade would have been easy to miss manually:</p>

<p><strong>The <code class="language-plaintext highlighter-rouge">distroless: true</code> placement.</strong> The flag needed to go <em>inside</em> the existing <code class="language-plaintext highlighter-rouge">image:</code> block in each env values file, not at a sibling level. Placing it at the wrong indentation level would have produced a duplicate <code class="language-plaintext highlighter-rouge">image:</code> key and silent YAML parsing behaviour. The agent correctly identified the existing block structure and inserted the flag at the right depth.</p>

<p><strong>The shields.io hyphen encoding.</strong> The badge URL for Grafana references <code class="language-plaintext highlighter-rouge">13.0.1-security-01</code>. In a shields.io URL, hyphens inside the badge value must be doubled: <code class="language-plaintext highlighter-rouge">13.0.1--security--01</code>. An obscure encoding rule the skill carries from when I first got it wrong. The agent applied it correctly without being prompted.</p>

<h2 id="what-makes-a-good-skill">What Makes a Good Skill</h2>

<p>A skill is essentially a runbook for an AI operator. A few things I learned from writing this one:</p>

<p><strong>Start with the process you actually follow, not an idealized one.</strong> The Prettier hook warning in Step 5 exists because I hit that bug in a real session. The double-hyphen badge encoding in Step 6 exists because I published a broken badge once. Skills work best when they encode real institutional knowledge including the failure modes, not just the happy path.</p>

<p><strong>Identify and state the invariants explicitly.</strong> “Never overwrite customised values with upstream defaults” is a constraint that the agent cannot infer from context alone. Without it, the agent has no way to know which values are intentionally different from upstream and which are just stale. Stating invariants as named rules means they are applied consistently without requiring per-upgrade review.</p>

<p><strong>Plan-first is non-negotiable for destructive work.</strong> The requirement to get explicit approval before any file is touched might feel like friction. It is not. The plan step consistently surfaces things I would not have noticed until after the fact (like the Grafana major version crossing in this upgrade) when they are cheap to act on. After approval, execution is fast because all the thinking is already done.</p>

<p><strong>Skills compose.</strong> The <code class="language-plaintext highlighter-rouge">helm-chart-upgrade</code> skill calls the <code class="language-plaintext highlighter-rouge">gitflow</code> skill for the commit step. Skills can depend on other skills, share conventions, and be invoked independently when needed.</p>

<h2 id="the-broader-pattern">The Broader Pattern</h2>

<p>Helm chart upgrades are one example of a class of work that is: well-defined, multi-step, error-prone when done manually, and repeated often enough that the accumulated cost of mistakes is real. Infrastructure work is full of this class of work.</p>

<p>The traditional automation answer is a Bash script. Scripts are great for deterministic sequences but brittle at the edges: they cannot handle ambiguity, cannot ask clarifying questions, and cannot adapt when upstream schema structure changes in ways you did not anticipate. A script that worked for v83 might silently corrupt an env file on v85 because a new key appeared in a position the script was not expecting.</p>

<p>An AI agent with encoded domain knowledge handles these edge cases differently. It can inspect what it finds, reason about whether a structural change is a breaking rename or a new addition, and surface the ambiguity for human review rather than silently doing the wrong thing. The skill file is the mechanism for transferring that knowledge from your head to the agent, written once and applied consistently.</p>

<p>The agent is not infallible. The <em>failure mode</em> is better: when the agent encounters something unexpected, it surfaces it rather than proceeding. That is a much more useful behaviour than a script that silently produces a broken output.</p>

<h2 id="getting-started-with-claude-code-skills">Getting Started with Claude Code Skills</h2>

<p>Skills live in <code class="language-plaintext highlighter-rouge">.claude/skills/&lt;skill-name&gt;/SKILL.md</code>. The format is plain markdown, no special syntax required. The agent reads the file as part of its context when you invoke <code class="language-plaintext highlighter-rouge">/skill-name</code>.</p>

<p>A few practical tips:</p>

<ul>
  <li>Write down the manual process you follow today, including the parts where you have been burned before.</li>
  <li>Identify the invariants, the things that must never change regardless of what upstream does. Write them as explicit constraints.</li>
  <li>Add a verification section. The agent should always prove the change is correct before committing, not just report that it thinks it is.</li>
  <li>Keep skills focused. One workflow per skill file is easier to reason about and compose than one large file trying to cover everything.</li>
</ul>

<p>If you are running Kubernetes in production and doing this kind of maintenance work manually, skills are worth the hour it takes to write one.</p>

<h2 id="the-broader-toolbox-loops-hooks-mcp-and-subagents">The Broader Toolbox: Loops, Hooks, MCP, and Subagents</h2>

<p>Skills are one piece of what Claude Code offers. The toolbox goes further, and the other parts matter for infrastructure work specifically.</p>

<p><strong>Loops</strong> let the agent run a workflow on a schedule or iterate until a condition is met. The same <code class="language-plaintext highlighter-rouge">helm-chart-upgrade</code> skill could be invoked in a loop that checks for new chart versions every morning, drafts the plan, and notifies you when there is something to approve. The agent does the reconnaissance; you do the approval. For teams managing a large number of charts across multiple clusters, this shifts chart maintenance from reactive (“someone noticed the chart is three versions behind”) to proactive (“I get a plan every time upstream ships”).</p>

<p><strong>Hooks</strong> are shell commands that fire automatically before or after agent tool calls. The Prettier hook that reformats YAML files after every edit is itself a hook, one the blog site uses to keep files formatted consistently. But hooks can do much more in an operations context: run <code class="language-plaintext highlighter-rouge">helm lint</code> after every values file edit to catch schema errors immediately, create a git checkpoint before any destructive operation, or send a notification when the agent completes a phase. Hooks let you wrap the agent’s actions with your own guardrails without modifying the skill itself; the skill defines the workflow, hooks enforce the environment constraints.</p>

<p><strong>MCP (Model Context Protocol)</strong> is the integration layer that connects agents to external systems as native tool calls. An agent with an MCP server for Azure can query Key Vault for secret metadata, check managed identity assignments, or verify federated credentials, without shell scripts or manual credential setup. For Kubernetes operations this opens up live cluster integrations: the agent can check whether a CRD version already exists before recommending a manual apply, verify that a Flux <code class="language-plaintext highlighter-rouge">HelmRelease</code> reached a <code class="language-plaintext highlighter-rouge">Ready</code> state after the pipeline ran, or inspect current resource limits before proposing changes. MCP turns the agent from a file editor into an operator with real-time visibility into the systems it is working on.</p>

<p><strong>Subagents</strong> let a parent agent delegate work to parallel child instances, each with their own context window and tool access. For the upgrade workflow, that means reconciling three environment values files concurrently rather than sequentially, or researching breaking changes across a range of chart versions without loading a dozen changelogs into a single context. The parent agent orchestrates; the subagents do the reading and return structured results. In wider infrastructure use, the pattern scales further: verify CRD state across multiple clusters in parallel, run <code class="language-plaintext highlighter-rouge">helm lint</code> against a matrix of chart and environment combinations, or fan out a secret rotation across namespaces and collect the results in one place.</p>

<p>Together these capabilities describe a continuum. At one end: a human types <code class="language-plaintext highlighter-rouge">/helm-chart-upgrade</code> and approves a plan. At the other: a scheduled loop detects a new upstream release, generates the plan, runs the pipeline, monitors Flux reconciliation via MCP, and pages you only when something needs a decision. The same skill file powers both ends of that continuum; the difference is just how much of the surrounding workflow you have also encoded.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The pace at which AI agents are changing the way we work is real and it is accelerating. A year ago, the idea of writing a markdown file and having an AI reliably execute a 13-step infrastructure workflow (pulling a chart, diffing schemas, syncing three environment files, updating pipeline configs, verifying renders, and committing) would have sounded optimistic. Today it is a Tuesday afternoon.</p>

<p>The important shift is not that the work gets done faster, although it does. The shift that matters is that the work gets done <em>consistently</em>. The agent does not skip the stale-reference grep because it is late. It does not forget the third pipeline block because the first two looked fine. It does not miss the Grafana major version crossing in the release notes because it was already familiar with the chart. Every execution follows the full process, including the parts that humans tend to abbreviate under time pressure.</p>

<p>What I want to emphasise is that AI agents like Claude are not primarily a tool for software developers writing application code. The example in this post has no application code in it at all; it is entirely infrastructure: YAML files, Helm charts, pipeline configurations, badge URLs. The agent performed exactly as well here as it would on a code refactoring task, because the underlying capability is not “write code” but “follow a complex process with context-dependent judgment.” That capability applies equally well to:</p>

<ul>
  <li>Rotating secrets across multiple Kubernetes namespaces following an audit trail</li>
  <li>Upgrading a Flux <code class="language-plaintext highlighter-rouge">HelmRelease</code> including drift detection and <code class="language-plaintext highlighter-rouge">valuesFrom</code> reconciliation</li>
  <li>Reviewing a pull request against a checklist of infrastructure security controls</li>
  <li>Onboarding a new cluster environment by replicating configuration from an existing one</li>
  <li>Auditing alert rule coverage against a list of SLOs</li>
</ul>

<p>These are not software engineering tasks in the traditional sense. They are IT operations tasks, the kind that platform engineers, SREs, and cloud infrastructure engineers do every day. They are also exactly the kind of tasks where consistency matters most and where the cost of human error is highest.</p>

<p>None of this means you stop paying attention. The plan step exists precisely because a human needs to review what the agent intends to do before it does it. Approving the plan for the kube-prometheus-stack upgrade took me about two minutes: scanning the image changes table, confirming the Grafana 13 note was flagged, checking that CRDs were correctly identified as unchanged. That two-minute review is far more valuable than the hour of manual execution it replaces, because it happens at the right moment: before anything is touched, when the cost of catching a mistake is zero. You are not removing the human from the loop; you are moving the human to the part of the loop where their judgment actually matters.</p>

<p>The question worth asking is not “can I use an AI agent for this?” but “what does my current process look like written down?” If the answer is a mental checklist you carry in your head, or a Confluence page that is three versions out of date, or a script that breaks when anything changes, that is a skill waiting to be written.</p>

<p>The world is not waiting for this to happen. It is already happening. The only variable is whether the institutional knowledge that makes your operations reliable ends up encoded somewhere useful, or stays locked in the heads of the people who do the work today.</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Kubernetes" /><category term="Helm" /><category term="AI" /><category term="Claude" /><category term="Claude Code" /><category term="Automation" /><category term="DevOps" /><category term="GitOps" /><category term="kube-prometheus-stack" /><category term="Platform Engineering" /><category term="AKS" /><category term="Azure" /><summary type="html"><![CDATA[A practical look at encoding domain knowledge into a reusable Claude Code skill, and what happened when I used it to upgrade kube-prometheus-stack from 83.5.1 to 85.1.3.]]></summary></entry><entry><title type="html">GitOps with Flux and Helm on AKS using Azure DevOps</title><link href="http://sysadminas.eu/GitOps-with-Flux-and-Helm/" rel="alternate" type="text/html" title="GitOps with Flux and Helm on AKS using Azure DevOps" /><published>2026-05-17T00:00:00+00:00</published><updated>2026-05-17T00:00:00+00:00</updated><id>http://sysadminas.eu/GitOps-with-Flux-and-Helm</id><content type="html" xml:base="http://sysadminas.eu/GitOps-with-Flux-and-Helm/"><![CDATA[<p><img align="right" width="400" height="300" src="../assets/images/post29/1.png" /></p>

<p>Over the past few months I have been building and operating a GitOps platform for Kubernetes clusters running on Azure. The driving motivation was a shift away from push-based CI/CD pipelines — where pipelines hold cluster credentials and <code class="language-plaintext highlighter-rouge">kubectl apply</code> changes directly — toward a pull-based model where an in-cluster operator continuously reconciles the live state against what Git says it should be. This post is a practical walkthrough of how I implemented that shift using Flux CD, the Flux Operator, Helm, and Azure DevOps.</p>

<p>If you are already familiar with basic GitOps concepts and want to see how things connect in a production-grade setup — including image scanning, signing, private registry mirroring, and a multi-environment onboarding model — this is the post for you.</p>

<h2 id="why-gitops">Why GitOps?</h2>

<p>GitOps is a way of managing infrastructure and application delivery where Git is the single source of truth. Any desired state change is made via a pull request, and an in-cluster operator continuously reconciles the live state against what Git says it should be. The key properties, as defined by the <a href="https://opengitops.dev/">OpenGitOps</a> principles, are:</p>

<ul>
  <li><strong>Declarative</strong> — the entire desired state is described declaratively</li>
  <li><strong>Versioned and immutable</strong> — Git history is the canonical audit trail</li>
  <li><strong>Pulled automatically</strong> — an agent in the cluster pulls state from Git (no external push into the cluster)</li>
  <li><strong>Continuously reconciled</strong> — if live state drifts from desired state, the agent corrects it</li>
</ul>

<p>For Kubernetes this model fits perfectly. Every resource is a YAML manifest, Git provides versioning and review workflows via pull requests, and controllers like Flux watch the repository and apply changes automatically. The alternative — pushing manifests from a CI/CD pipeline with <code class="language-plaintext highlighter-rouge">kubectl apply</code> — requires the pipeline to have cluster credentials, creates a push-based model that bypasses drift detection, and gives you no built-in reconciliation if someone applies something directly to the cluster.</p>

<p>With the “why” clear, let me walk through how these principles are put into practice: what the repository looks like, how the pipeline fits in, and where Flux takes over.</p>

<h2 id="architecture-overview">Architecture Overview</h2>

<h3 id="flux-architecture">Flux Architecture</h3>

<p>Flux CD is a set of Kubernetes controllers, each responsible for a specific piece of the GitOps loop. Understanding what each controller does makes the rest of the setup much easier to reason about:</p>

<ul>
  <li><strong>source-controller</strong> — watches Git repositories, Helm repositories, OCI registries, and S3-compatible buckets. It fetches content and makes it available as an in-cluster artifact that other controllers consume. This is the only controller that talks to external sources.</li>
  <li><strong>kustomize-controller</strong> — watches <code class="language-plaintext highlighter-rouge">Kustomization</code> CRDs and applies the manifests they point to (plain YAML, Kustomize overlays, or files that happen to contain <code class="language-plaintext highlighter-rouge">HelmRelease</code> objects). It handles decryption, dependency ordering, health checks, and pruning of removed resources.</li>
  <li><strong>helm-controller</strong> — watches <code class="language-plaintext highlighter-rouge">HelmRelease</code> CRDs and manages the full Helm lifecycle: install, upgrade, rollback, and uninstall. It reads chart artifacts from source-controller and renders them server-side.</li>
  <li><strong>notification-controller</strong> — handles inbound webhooks (to trigger reconciliation) and outbound event notifications (Slack, Teams, GitHub commit status, etc.).</li>
  <li><strong>image-reflector-controller</strong> and <strong>image-automation-controller</strong> — together they implement image update automation: scanning a registry for new tags and writing the updated tag back to Git.</li>
</ul>

<p>These controllers communicate through the Kubernetes API only; they never talk directly to each other. source-controller produces <code class="language-plaintext highlighter-rouge">Artifact</code> objects; kustomize-controller and helm-controller consume them. This loose coupling means you can enable only the controllers you need.</p>

<h3 id="platform-layout">Platform Layout</h3>

<p>The platform manages multiple Kubernetes environments. The branch-to-environment mapping is straightforward:</p>

<table>
  <thead>
    <tr>
      <th>Git Branch</th>
      <th>Environment</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">experimental</code></td>
      <td>experimental</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dev</code></td>
      <td>dev</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">master</code></td>
      <td>prod</td>
    </tr>
  </tbody>
</table>

<p>Any push to these branches triggers an Azure DevOps pipeline that handles the infrastructure layer: scanning and mirroring Flux controller images to a private Azure Container Registry (ACR), deploying the Flux Operator via Helm, and applying the <code class="language-plaintext highlighter-rouge">FluxInstance</code> CRD that tells Flux how to configure itself. Once Flux is running, it takes over and continuously reconciles everything under <code class="language-plaintext highlighter-rouge">clusters/&lt;env&gt;/systems/</code>. No pipeline trigger required for day-to-day system changes.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Git Push / PR
     │
     ├── clusters/*/flux-instance.yaml           clusters/*/systems/** (PR only)
     │   clusters/*/flux-operator-values.yaml              │
     │   flux-operator/**                                  │
     ▼                                                     ▼
azure-pipelines.yaml                        azure-pipelines-systems.yaml
     │                                                     │
     ├── [On PR] Validate stage                [On PR] Validate stage
     │     ├── Scan Flux images (Trivy)            └── kubectl apply --dry-run=server
     │     ├── Helm dry-run of Flux Operator              clusters/&lt;env&gt;/systems/
     │     └── kubectl dry-run of FluxInstance
     │
     └── [On push] Apply stage
           ├── Scan images (Trivy)
           ├── Mirror images to private ACR (crane)
           ├── Sign images (Cosign)
           ├── Helm upgrade --install Flux Operator
           └── kubectl apply FluxInstance
                     │
                     ▼
              Flux Operator reconciles FluxInstance
                     │
                     ▼
              Flux controllers deployed in cluster
                     │
                     ▼
              Flux syncs clusters/&lt;env&gt;/systems/ from Git
                     │
                     ├── Reconciles GitRepository sources (system repos)
                     └── Reconciles Kustomization → HelmRelease or plain YAML
</code></pre></div></div>

<h3 id="repository-structure">Repository Structure</h3>

<p>The <code class="language-plaintext highlighter-rouge">k8s-gitops</code> repository is the single source of truth for the platform layer. Its layout reflects the branch-per-environment model directly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k8s-gitops/
├── azure-pipelines.yaml               # Main pipeline — triggers and environment wiring
├── azure-pipelines-systems.yaml       # Systems manifest validation pipeline — PR-only
├── ci-cd-templates/                   # Reusable Azure DevOps pipeline templates
│   ├── flux.yaml                      # Top-level: validate (on PR) + apply (on push)
│   ├── flux-operator.yaml             # Helm deploy/upgrade of Flux Operator
│   ├── flux-instance.yaml             # kubectl apply of FluxInstance CRD
│   ├── flux-images.yaml               # Mirror → scan → sign each Flux image
│   ├── scan-image.yaml                # Trivy vulnerability scan step
│   ├── sign-image.yaml                # Cosign image signing step
│   └── systems-validate.yaml          # kubectl dry-run validation of systems/ manifests
├── clusters/
│   ├── experimental/
│   │   ├── flux-instance.yaml         # FluxInstance spec for this environment
│   │   ├── flux-operator-values.yaml  # Helm values for Flux Operator
│   │   └── systems/                   # Flux-managed resources (reconciled by Flux, not the pipeline)
│   │       └── &lt;system&gt;.yaml          # One file per system — GitRepository + Kustomization
│   ├── dev/
│   └── prod/
└── flux-operator/                     # Helm chart for the Flux Operator (vendored)
</code></pre></div></div>

<p>The key separation is between <code class="language-plaintext highlighter-rouge">clusters/&lt;env&gt;/flux-instance.yaml</code> and <code class="language-plaintext highlighter-rouge">clusters/&lt;env&gt;/systems/</code>:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">flux-instance.yaml</code> and <code class="language-plaintext highlighter-rouge">flux-operator-values.yaml</code> are managed by the Azure DevOps pipeline — they define Flux itself.</li>
  <li>Everything under <code class="language-plaintext highlighter-rouge">systems/</code> is managed by Flux directly — the pipeline never touches it.</li>
</ul>

<h2 id="flux-operator-and-fluxinstance-crd">Flux Operator and FluxInstance CRD</h2>

<p>One of the first architectural decisions was how to install and lifecycle-manage Flux itself. The traditional approach — <code class="language-plaintext highlighter-rouge">flux bootstrap</code> — generates YAML and commits it to your repo. It works, but upgrading Flux means re-running bootstrap or hand-editing generated files. There is no declarative upgrade path.</p>

<p>The <a href="https://fluxoperator.dev/">Flux Operator</a> solves this. It introduces a <code class="language-plaintext highlighter-rouge">FluxInstance</code> CRD that lets you declare Flux’s desired state the same way you declare anything else in Kubernetes. The operator reads the spec and takes care of deploying, configuring, and upgrading the Flux controllers. You never run <code class="language-plaintext highlighter-rouge">flux bootstrap</code> again.</p>

<p>Installing the Flux Operator is a single Helm command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>helm upgrade <span class="nt">--install</span> flux-operator ./flux-operator <span class="se">\</span>
  <span class="nt">--namespace</span> flux-system <span class="se">\</span>
  <span class="nt">--create-namespace</span> <span class="se">\</span>
  <span class="nt">-f</span> ./clusters/&lt;<span class="nb">env</span><span class="o">&gt;</span>/flux-operator-values.yaml
</code></pre></div></div>

<p>Once the operator is running, you apply the <code class="language-plaintext highlighter-rouge">FluxInstance</code> manifest:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">fluxcd.controlplane.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">FluxInstance</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">flux</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">distribution</span><span class="pi">:</span>
    <span class="na">version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">2.8.3"</span> <span class="c1"># Flux version to deploy</span>
    <span class="na">registry</span><span class="pi">:</span> <span class="s2">"</span><span class="s">myacr.azurecr.io/fluxcd"</span> <span class="c1"># Private ACR where images are mirrored</span>
    <span class="na">variant</span><span class="pi">:</span> <span class="s2">"</span><span class="s">upstream-alpine"</span> <span class="c1"># Required when using a private registry</span>
  <span class="na">components</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">source-controller</span>
    <span class="pi">-</span> <span class="s">kustomize-controller</span>
    <span class="pi">-</span> <span class="s">helm-controller</span>
    <span class="pi">-</span> <span class="s">notification-controller</span>
    <span class="pi">-</span> <span class="s">image-reflector-controller</span>
    <span class="pi">-</span> <span class="s">image-automation-controller</span>
  <span class="na">sync</span><span class="pi">:</span>
    <span class="na">kind</span><span class="pi">:</span> <span class="s">GitRepository</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">azure</span> <span class="c1"># Azure DevOps authentication</span>
    <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://dev.azure.com/my-org/my-project/_git/k8s-gitops"</span>
    <span class="na">ref</span><span class="pi">:</span> <span class="s2">"</span><span class="s">refs/heads/experimental"</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">clusters/experimental/systems"</span> <span class="c1"># Flux watches this path</span>
  <span class="na">kustomize</span><span class="pi">:</span>
    <span class="na">patches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">...</span><span class="pi">]</span> <span class="c1"># Workload identity patches (see below)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">spec.distribution</code> block is the key part. When you increment the version number and apply the manifest, the Flux Operator reconciles the change and upgrades all controllers automatically. No manual rollout, no bootstrap re-run. The operator also handles the case where the controllers drift from the declared state and corrects them.</p>

<p>All six Flux controllers are deployed: <code class="language-plaintext highlighter-rouge">source-controller</code>, <code class="language-plaintext highlighter-rouge">kustomize-controller</code>, <code class="language-plaintext highlighter-rouge">helm-controller</code>, <code class="language-plaintext highlighter-rouge">notification-controller</code>, <code class="language-plaintext highlighter-rouge">image-reflector-controller</code>, and <code class="language-plaintext highlighter-rouge">image-automation-controller</code>.</p>

<h2 id="azure-workload-identity--secretless-git-authentication">Azure Workload Identity — Secretless Git Authentication</h2>

<p>Flux’s <code class="language-plaintext highlighter-rouge">source-controller</code> needs to pull from Azure DevOps Git repositories. The naive approach is to create a Personal Access Token (PAT), store it as a Kubernetes secret, and reference it in the <code class="language-plaintext highlighter-rouge">GitRepository</code> spec. This works but PATs expire, need rotation, and storing credentials in the cluster creates a credential management burden.</p>

<p>The better approach is <a href="https://azure.github.io/azure-workload-identity/docs/">Azure Workload Identity</a>. The idea is to federate a Kubernetes service account with an Azure Managed Identity. When the <code class="language-plaintext highlighter-rouge">source-controller</code> pod authenticates to Azure DevOps, it uses the federated credential, with no secrets stored anywhere.</p>

<p>The setup involves three steps:</p>

<p><strong>1. Create a Managed Identity with a federated credential:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create the managed identity</span>
az identity create <span class="se">\</span>
  <span class="nt">--name</span> aks-dev-flux-source-controller <span class="se">\</span>
  <span class="nt">--resource-group</span> my-cluster-rg <span class="se">\</span>
  <span class="nt">--subscription</span> <span class="s2">"my-subscription"</span>

<span class="c"># Create the federated credential linking it to the source-controller service account</span>
az identity federated-credential create <span class="se">\</span>
  <span class="nt">--name</span> aks-dev-flux-federated-credential <span class="se">\</span>
  <span class="nt">--identity-name</span> aks-dev-flux-source-controller <span class="se">\</span>
  <span class="nt">--resource-group</span> my-cluster-rg <span class="se">\</span>
  <span class="nt">--issuer</span> &lt;AKS_OIDC_ISSUER_URL&gt; <span class="se">\</span>
  <span class="nt">--subject</span> system:serviceaccount:flux-system:source-controller
</code></pre></div></div>

<p><strong>2. Grant the identity read access to your Azure DevOps project</strong> — a project administrator adds it to the Readers group in Project Settings → Permissions.</p>

<p><strong>3. Patch the <code class="language-plaintext highlighter-rouge">source-controller</code> service account and deployment</strong> via <code class="language-plaintext highlighter-rouge">FluxInstance.spec.kustomize.patches</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">kustomize</span><span class="pi">:</span>
  <span class="na">patches</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">patch</span><span class="pi">:</span> <span class="pi">|-</span>
        <span class="s">apiVersion: v1</span>
        <span class="s">kind: ServiceAccount</span>
        <span class="s">metadata:</span>
          <span class="s">name: source-controller</span>
          <span class="s">annotations:</span>
            <span class="s">azure.workload.identity/client-id: &lt;AZURE_CLIENT_ID&gt;</span>
            <span class="s">azure.workload.identity/tenant-id: &lt;AZURE_TENANT_ID&gt;</span>
      <span class="na">target</span><span class="pi">:</span>
        <span class="na">kind</span><span class="pi">:</span> <span class="s">ServiceAccount</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">source-controller</span>
    <span class="pi">-</span> <span class="na">patch</span><span class="pi">:</span> <span class="pi">|-</span>
        <span class="s">apiVersion: apps/v1</span>
        <span class="s">kind: Deployment</span>
        <span class="s">metadata:</span>
          <span class="s">name: source-controller</span>
        <span class="s">spec:</span>
          <span class="s">template:</span>
            <span class="s">metadata:</span>
              <span class="s">labels:</span>
                <span class="s">azure.workload.identity/use: "true"</span>
      <span class="na">target</span><span class="pi">:</span>
        <span class="na">kind</span><span class="pi">:</span> <span class="s">Deployment</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">source-controller</span>
</code></pre></div></div>

<p>The Flux Operator applies these patches when it deploys the controllers, so the workload identity configuration is fully declarative and version-controlled alongside everything else.</p>

<h2 id="cicd-pipeline-images-scanning-signing">CI/CD Pipeline: Images, Scanning, Signing</h2>

<p>The entry point for the whole Flux infrastructure pipeline is <code class="language-plaintext highlighter-rouge">azure-pipelines.yaml</code>. It triggers on pushes to the three environment branches (or on PRs touching those paths) and calls the <code class="language-plaintext highlighter-rouge">flux.yaml</code> template once per environment, passing the image list and environment-specific parameters:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># azure-pipelines.yaml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">k8s-gitops-flux</span>

<span class="na">trigger</span><span class="pi">:</span>
  <span class="na">branches</span><span class="pi">:</span>
    <span class="na">include</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">experimental</span>
      <span class="pi">-</span> <span class="s">master</span>
      <span class="pi">-</span> <span class="s">dev</span>
  <span class="na">paths</span><span class="pi">:</span>
    <span class="na">include</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">clusters/*/flux-instance.yaml</span>
      <span class="pi">-</span> <span class="s">clusters/*/flux-operator-values.yaml</span>
      <span class="pi">-</span> <span class="s">flux-operator/*</span>

<span class="na">pool</span><span class="pi">:</span> <span class="s">my-linux-agents</span>

<span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmReleaseName</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">flux-operator"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmReleaseNamespace</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">flux-system"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmChartPath</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./flux-operator"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmCreateNamespace</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
    <span class="na">default</span><span class="pi">:</span> <span class="no">true</span>

<span class="na">stages</span><span class="pi">:</span>
  <span class="c1"># Experimental cluster CI/CD</span>
  <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">ci-cd-templates/flux.yaml</span>
    <span class="na">parameters</span><span class="pi">:</span>
      <span class="na">environment</span><span class="pi">:</span> <span class="s2">"</span><span class="s">experimental"</span>
      <span class="na">branch</span><span class="pi">:</span> <span class="s2">"</span><span class="s">experimental"</span>
      <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s2">"</span><span class="s">k8s-rbac-experimental-ado-sc"</span>
      <span class="na">helmReleaseName</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmReleaseNamespace</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmChartPath</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmValuesFilePath</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./clusters/experimental/flux-operator-values.yaml"</span>
      <span class="na">helmCreateNamespace</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">containerImages</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">ghcr.io/controlplaneio-fluxcd/flux-operator:v0.45.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/helm-controller:v1.5.3</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/image-automation-controller:v1.1.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/image-reflector-controller:v1.1.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/kustomize-controller:v1.8.2</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/notification-controller:v1.8.2</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/source-controller:v1.8.1</span>

  <span class="c1"># Dev cluster CI/CD</span>
  <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">ci-cd-templates/flux.yaml</span>
    <span class="na">parameters</span><span class="pi">:</span>
      <span class="na">environment</span><span class="pi">:</span> <span class="s2">"</span><span class="s">dev"</span>
      <span class="na">branch</span><span class="pi">:</span> <span class="s2">"</span><span class="s">dev"</span>
      <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s2">"</span><span class="s">k8s-rbac-dev-ado-sc"</span>
      <span class="na">helmReleaseName</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmReleaseNamespace</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmChartPath</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmValuesFilePath</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./clusters/dev/flux-operator-values.yaml"</span>
      <span class="na">helmCreateNamespace</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">containerImages</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">ghcr.io/controlplaneio-fluxcd/flux-operator:v0.45.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/helm-controller:v1.5.3</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/image-automation-controller:v1.1.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/image-reflector-controller:v1.1.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/kustomize-controller:v1.8.2</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/notification-controller:v1.8.2</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/source-controller:v1.8.1</span>

  <span class="c1"># Production cluster CI/CD</span>
  <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">ci-cd-templates/flux.yaml</span>
    <span class="na">parameters</span><span class="pi">:</span>
      <span class="na">environment</span><span class="pi">:</span> <span class="s2">"</span><span class="s">prod"</span>
      <span class="na">branch</span><span class="pi">:</span> <span class="s2">"</span><span class="s">master"</span>
      <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s2">"</span><span class="s">k8s-rbac-prod-ado-sc"</span>
      <span class="na">helmReleaseName</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmReleaseNamespace</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmChartPath</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">helmValuesFilePath</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./clusters/prod/flux-operator-values.yaml"</span>
      <span class="na">helmCreateNamespace</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">containerImages</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">ghcr.io/controlplaneio-fluxcd/flux-operator:v0.45.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/helm-controller:v1.5.3</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/image-automation-controller:v1.1.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/image-reflector-controller:v1.1.1</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/kustomize-controller:v1.8.2</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/notification-controller:v1.8.2</span>
        <span class="pi">-</span> <span class="s">ghcr.io/fluxcd/source-controller:v1.8.1</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">containerImages</code> list is what the <code class="language-plaintext highlighter-rouge">flux-images.sh</code> script generates — on every upgrade you replace this block and nothing else.</p>

<p>One of the things that took the most thought was the image management story. Flux controller images come from <code class="language-plaintext highlighter-rouge">ghcr.io/fluxcd</code> (upstream) or <code class="language-plaintext highlighter-rouge">ghcr.io/controlplaneio-fluxcd</code> (enterprise). Pulling images directly from public registries in production clusters is a risk: rate limits, availability dependencies, no supply chain verification. The solution is to mirror all images to a private ACR on every release.</p>

<p>The pipeline handles this for each image:</p>

<p><strong>1. Scan with Trivy</strong> — before copying anything, the source image is scanned for vulnerabilities. In <code class="language-plaintext highlighter-rouge">audit</code> mode the pipeline logs findings without failing; switching to <code class="language-plaintext highlighter-rouge">enforce</code> mode fails the pipeline on HIGH or CRITICAL CVEs. The Trivy database itself is hosted in private ACR to avoid GHCR rate limiting (I wrote about this in a <a href="https://sysadminas.eu/Host-Trivy-DB-in-ACR/">previous post</a>).</p>

<p><strong>2. Mirror with crane</strong> — <code class="language-plaintext highlighter-rouge">crane copy</code> copies the image directly between registries without pulling it locally. Critically, it preserves the full OCI manifest index, so the digest in ACR is identical to the upstream digest. This matters for Cosign signature verification — the signature is tied to the digest. The Flux Operator uses the digest for flux-controller images by default, so the mirrored image must have the same digest for the signature to be valid. If signature verification fails, the pipeline fails. The same signature verification is enforced in the cluster before allowing the workload to run, thanks to Kyverno.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>crane copy <span class="se">\</span>
  ghcr.io/fluxcd/source-controller:v1.4.3 <span class="se">\</span>
  myacr.azurecr.io/fluxcd/source-controller:v1.4.3
</code></pre></div></div>

<p><strong>3. Sign with Cosign</strong> — after copying, the image is signed using a key stored in Azure Key Vault. The signature is pushed to ACR alongside the image. Signing only runs on push (not on PRs), and after signing the pipeline verifies the signature before proceeding.</p>

<p>All of this runs for every image in the <code class="language-plaintext highlighter-rouge">containerImages</code> list defined in <code class="language-plaintext highlighter-rouge">azure-pipelines.yaml</code>. When upgrading Flux, a helper script generates the updated image list:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./scripts/flux-images.sh v2.8.3 v0.45.1
</code></pre></div></div>

<p>The script uses the <code class="language-plaintext highlighter-rouge">flux</code> CLI and <code class="language-plaintext highlighter-rouge">yq</code> to produce a ready-to-paste <code class="language-plaintext highlighter-rouge">containerImages</code> block. You replace the list in <code class="language-plaintext highlighter-rouge">azure-pipelines.yaml</code>, update <code class="language-plaintext highlighter-rouge">spec.distribution.version</code> in each <code class="language-plaintext highlighter-rouge">FluxInstance</code>, open a PR to <code class="language-plaintext highlighter-rouge">experimental</code>, validate, then promote to <code class="language-plaintext highlighter-rouge">dev</code> and <code class="language-plaintext highlighter-rouge">master</code>.</p>

<p>The pipeline templates are split into focused reusable files:</p>

<table>
  <thead>
    <tr>
      <th>Template</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flux.yaml</code></td>
      <td>Top-level orchestration — delegates to validate or apply based on trigger</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flux-validate.yaml</code></td>
      <td>PR: scan images, Helm dry-run, kubectl dry-run of FluxInstance</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flux-apply.yaml</code></td>
      <td>Push: scan + mirror + sign images, Helm upgrade, apply FluxInstance</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flux-images.yaml</code></td>
      <td>Processes each image: Trivy → crane → Cosign</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flux-operator.yaml</code></td>
      <td>Helm upgrade/install of the Flux Operator</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">flux-instance.yaml</code></td>
      <td>Apply FluxInstance, wait for Ready condition</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">common-tools.yaml</code></td>
      <td>Install kubectl, kubelogin, yq, trivy, crane, cosign, helm — with SHA256 verification</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">systems-validate.yaml</code></td>
      <td>PR-only: <code class="language-plaintext highlighter-rouge">kubectl apply --dry-run=server</code> of systems manifests</td>
    </tr>
  </tbody>
</table>

<p>Every tool installation in <code class="language-plaintext highlighter-rouge">common-tools.yaml</code> downloads from the official release URL and verifies the SHA256 checksum before installing. If the checksum does not match, the step fails immediately. A small but important supply chain control.</p>

<p>Here is how the three core image pipeline templates look in practice.</p>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/flux-images.yaml</code></strong> — iterates over the image list and calls scan → copy → sign for each one:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerImages</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">object</span>
    <span class="na">default</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryName</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryServiceConnection</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">scanMode</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">audit"</span> <span class="c1"># switch to 'enforce' to fail on HIGH/CRITICAL</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">scanImages</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
    <span class="na">default</span><span class="pi">:</span> <span class="no">false</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">signImages</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
    <span class="na">default</span><span class="pi">:</span> <span class="no">false</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">environment</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">imageSignerServiceConnection</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>

<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Docker@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Login</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">Container</span><span class="nv"> </span><span class="s">Registry"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">command</span><span class="pi">:</span> <span class="s">login</span>
      <span class="na">containerRegistry</span><span class="pi">:</span> <span class="s">$</span>

  <span class="pi">-</span> <span class="na">$</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">IMAGE_PATH=$(echo "$" | cut -d'/' -f2-)</span>
          <span class="s">TARGET_IMAGE="$/$IMAGE_PATH"</span>
          <span class="s">echo "##vso[task.setvariable variable=TARGET_IMAGE;]$TARGET_IMAGE"</span>
        <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Prepare</span><span class="nv"> </span><span class="s">target</span><span class="nv"> </span><span class="s">image</span><span class="nv"> </span><span class="s">name</span><span class="nv"> </span><span class="s">$"</span>

      <span class="pi">-</span> <span class="na">$</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">scan-image.yaml</span>
            <span class="na">parameters</span><span class="pi">:</span>
              <span class="na">image</span><span class="pi">:</span> <span class="s">$</span>
              <span class="na">scanMode</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">crane copy $ $(TARGET_IMAGE)</span>
        <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Copy</span><span class="nv"> </span><span class="s">$"</span>
        <span class="na">condition</span><span class="pi">:</span> <span class="s">and(succeeded(), not(startsWith(variables['Build.SourceBranch'], 'refs/pull')))</span>

      <span class="pi">-</span> <span class="na">$</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">sign-image.yaml</span>
            <span class="na">parameters</span><span class="pi">:</span>
              <span class="na">image</span><span class="pi">:</span> <span class="s">$(TARGET_IMAGE)</span>
              <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s">$</span>
              <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Docker@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Logout</span><span class="nv"> </span><span class="s">of</span><span class="nv"> </span><span class="s">Container</span><span class="nv"> </span><span class="s">Registry"</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">always()</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">command</span><span class="pi">:</span> <span class="s">logout</span>
      <span class="na">containerRegistry</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/scan-image.yaml</code></strong> — runs Trivy with two modes: audit (log only) and enforce (fail on HIGH/CRITICAL):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">image</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryName</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">scanMode</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">audit"</span>

<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">trivy image \</span>
        <span class="s">--scanners vuln \</span>
        <span class="s">--ignore-unfixed \</span>
        <span class="s">--pkg-types os \</span>
        <span class="s">--exit-code 0 \</span>
        <span class="s">--db-repository=$/trivy/trivy-db:2 \</span>
        <span class="s">--java-db-repository=$/trivy/trivy-java-db:1 \</span>
        <span class="s">$</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">eq('$', 'audit')</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Scan</span><span class="nv"> </span><span class="s">$</span><span class="nv"> </span><span class="s">(audit)"</span>

  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s"># LOW/MEDIUM — informational only</span>
      <span class="s">trivy image --exit-code 0 --severity LOW,MEDIUM \</span>
        <span class="s">--ignore-unfixed --pkg-types os \</span>
        <span class="s">--db-repository=$/trivy/trivy-db:2 \</span>
        <span class="s">$</span>

      <span class="s"># HIGH/CRITICAL — fail the pipeline</span>
      <span class="s">trivy image --exit-code 1 --severity HIGH,CRITICAL \</span>
        <span class="s">--ignore-unfixed --pkg-types os \</span>
        <span class="s">--db-repository=$/trivy/trivy-db:2 \</span>
        <span class="s">$</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">eq('$', 'enforce')</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Scan</span><span class="nv"> </span><span class="s">$</span><span class="nv"> </span><span class="s">(enforce)"</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/sign-image.yaml</code></strong> — resolves the digest via <code class="language-plaintext highlighter-rouge">az acr</code>, signs with <code class="language-plaintext highlighter-rouge">cosign</code> using an Azure Key Vault key, then immediately verifies the signature:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">image</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">serviceConnection</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">environment</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">string</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>

<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Sign</span><span class="nv"> </span><span class="s">$"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">REGISTRY=$(echo "$" | cut -d'/' -f1)</span>
        <span class="s">REPOSITORY=$(echo "$" | cut -d'/' -f2- | cut -d':' -f1)</span>
        <span class="s">TAG=$(echo "$" | grep -o ':[^@]*$' | cut -d':' -f2)</span>

        <span class="s">IMAGE_DIGEST=$(az acr repository show \</span>
          <span class="s">--name $REGISTRY \</span>
          <span class="s">--image $REPOSITORY:$TAG \</span>
          <span class="s">--query "digest" -o tsv)</span>

        <span class="s">if [ "$" == "prod" ]; then</span>
          <span class="s">KV_NAME="my-prod-keyvault"</span>
          <span class="s">KV_KEY="my-prod-cosign-key"</span>
        <span class="s">else</span>
          <span class="s">KV_NAME="my-dev-keyvault"</span>
          <span class="s">KV_KEY="my-dev-cosign-key"</span>
        <span class="s">fi</span>

        <span class="s">cosign sign \</span>
          <span class="s">--key azurekms://$KV_NAME.vault.azure.net/$KV_KEY \</span>
          <span class="s">$REGISTRY/$REPOSITORY@$IMAGE_DIGEST \</span>
          <span class="s">--upload=true --yes=true --tlog-upload=false</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Verify</span><span class="nv"> </span><span class="s">$</span><span class="nv"> </span><span class="s">signature"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">if [ "$" == "prod" ]; then</span>
          <span class="s">SECRET_NAME="cosign-public-key-prod"</span>
          <span class="s">KV_NAME="my-prod-keyvault"</span>
        <span class="s">else</span>
          <span class="s">SECRET_NAME="cosign-public-key-dev"</span>
          <span class="s">KV_NAME="my-dev-keyvault"</span>
        <span class="s">fi</span>

        <span class="s">az keyvault secret show \</span>
          <span class="s">--vault-name $KV_NAME --name $SECRET_NAME \</span>
          <span class="s">--query value -o tsv &gt; cosign.pub</span>

        <span class="s">cosign verify \</span>
          <span class="s">--key cosign.pub \</span>
          <span class="s">$ \</span>
          <span class="s">--private-infrastructure=true</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/flux.yaml</code></strong> — top-level orchestrator: routes to validate on PRs and to apply on push:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">branch</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">environment</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">serviceConnection</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerImages</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">object</span>
    <span class="na">default</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">scanMode</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">audit"</span>
    <span class="na">values</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">audit</span><span class="pi">,</span> <span class="nv">enforce</span><span class="pi">]</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmReleaseName</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmReleaseNamespace</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmChartPath</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmValuesFilePath</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmArgs</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">object</span>
    <span class="na">default</span><span class="pi">:</span> <span class="pi">[]</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmCreateNamespace</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
    <span class="na">default</span><span class="pi">:</span> <span class="no">true</span>

<span class="na">stages</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s2">"</span><span class="s">ValidateFluxConfiguration_in_$_cluster"</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Validate Flux configuration in $ cluster</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">startsWith(variables['Build.SourceBranch'], 'refs/pull')</span>
    <span class="na">jobs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-validate.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerImages</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">scanImages</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">scanMode</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">signImages</span><span class="pi">:</span> <span class="no">false</span>
          <span class="na">helmReleaseName</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmReleaseNamespace</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmChartPath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmValuesFilePath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmArgs</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmCreateNamespace</span><span class="pi">:</span> <span class="s">$</span>

  <span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s2">"</span><span class="s">ApplyFluxConfiguration_to_$_cluster"</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Apply Flux configuration to $ cluster</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">eq(variables['Build.SourceBranchName'], '$')</span>
    <span class="na">jobs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-apply.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerImages</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">scanImages</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">scanMode</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">signImages</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">helmReleaseName</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmReleaseNamespace</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmChartPath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmValuesFilePath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmArgs</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmCreateNamespace</span><span class="pi">:</span> <span class="s">$</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/flux-validate.yaml</code></strong> — PR validation job: scan images, Helm dry-run, FluxInstance dry-run. Note the per-environment ACR and service connection variables resolved at job level:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">ValidateFluxConfiguration</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Validate Flux configuration in $ cluster</span>
    <span class="na">variables</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryName</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">myacr-prod.azurecr.io</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">myacr-dev.azurecr.io</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryServiceConnection</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">acr-prod-sc</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">acr-dev-sc</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">imageSignerServiceConnection</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">image-signing-prod-sc</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">image-signing-dev-sc</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">common-tools.yaml</span>

      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-images.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerRegistryName</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerRegistryServiceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">imageSignerServiceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerImages</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">scanImages</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">signImages</span><span class="pi">:</span> <span class="s">$</span> <span class="c1"># false on PR</span>
          <span class="na">scanMode</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-operator.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmReleaseName</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmReleaseNamespace</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmChartPath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmValuesFilePath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmDryRun</span><span class="pi">:</span> <span class="no">true</span> <span class="c1"># dry-run only on PR</span>

      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-instance.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">waitTimeout</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3m"</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/flux-apply.yaml</code></strong> — push apply job: same structure as validate but <code class="language-plaintext highlighter-rouge">signImages: true</code> and <code class="language-plaintext highlighter-rouge">helmDryRun: false</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">ApplyFluxConfiguration</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Apply Flux configuration to $ cluster</span>
    <span class="na">variables</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryName</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">myacr-prod.azurecr.io</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">myacr-dev.azurecr.io</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryServiceConnection</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">acr-prod-sc</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">acr-dev-sc</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">imageSignerServiceConnection</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">image-signing-prod-sc</span>
        <span class="na">$</span><span class="pi">:</span>
          <span class="na">value</span><span class="pi">:</span> <span class="s">image-signing-dev-sc</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">common-tools.yaml</span>

      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-images.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerRegistryName</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerRegistryServiceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">imageSignerServiceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">containerImages</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">scanImages</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">signImages</span><span class="pi">:</span> <span class="s">$</span> <span class="c1"># true on push</span>
          <span class="na">scanMode</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-operator.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmReleaseName</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmReleaseNamespace</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmChartPath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmValuesFilePath</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">helmDryRun</span><span class="pi">:</span> <span class="no">false</span> <span class="c1"># real install on push</span>

      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">flux-instance.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">waitTimeout</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3m"</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/flux-operator.yaml</code></strong> — Helm template, dry-run, and upgrade of the Flux Operator; credentials are fetched and cleaned up around the Helm steps:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">environment</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">serviceConnection</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmReleaseName</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmReleaseNamespace</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmChartPath</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmValuesFilePath</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmDryRun</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
    <span class="na">default</span><span class="pi">:</span> <span class="no">true</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmCreateNamespace</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">boolean</span>
    <span class="na">default</span><span class="pi">:</span> <span class="no">true</span>

<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Get</span><span class="nv"> </span><span class="s">AKS</span><span class="nv"> </span><span class="s">credentials"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">kubelogin remove-cache-dir</span>
        <span class="s">az aks get-credentials \</span>
          <span class="s">--name aks-$ \</span>
          <span class="s">--resource-group aks-rg-$ \</span>
          <span class="s">--overwrite-existing</span>
        <span class="s">kubelogin convert-kubeconfig -l azurecli</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Helm</span><span class="nv"> </span><span class="s">Template</span><span class="nv"> </span><span class="s">$"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">helm template $ \</span>
          <span class="s">./$ \</span>
          <span class="s">-f ./$</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Helm</span><span class="nv"> </span><span class="s">Dry-Run</span><span class="nv"> </span><span class="s">$"</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">eq('$', 'true')</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">helm upgrade $ \</span>
          <span class="s">./$ \</span>
          <span class="s">-f ./$ \</span>
          <span class="s">--namespace $ \</span>
          <span class="s">--create-namespace --dry-run --install</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Helm</span><span class="nv"> </span><span class="s">Install</span><span class="nv"> </span><span class="s">$"</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">and(succeeded(), not(startsWith(variables['Build.SourceBranch'], 'refs/pull')))</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">helm upgrade $ \</span>
          <span class="s">./$ \</span>
          <span class="s">-f ./$ \</span>
          <span class="s">--namespace $ \</span>
          <span class="s">--create-namespace --install --wait</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Cleanup</span><span class="nv"> </span><span class="s">AKS</span><span class="nv"> </span><span class="s">credentials"</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">always()</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">kubelogin remove-cache-dir</span>
        <span class="s">kubectl config delete-context aks-$ 2&gt;/dev/null || true</span>
        <span class="s">kubectl config delete-cluster aks-$ 2&gt;/dev/null || true</span>
        <span class="s">echo "Credentials cleaned up ✓"</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/flux-instance.yaml</code></strong> — validates the <code class="language-plaintext highlighter-rouge">FluxInstance</code> spec using the <code class="language-plaintext highlighter-rouge">flux-operator</code> CLI, applies it, and waits for the <code class="language-plaintext highlighter-rouge">Ready</code> condition. Skips the apply step on PRs:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">environment</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">serviceConnection</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">waitTimeout</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">3m"</span>

<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Get</span><span class="nv"> </span><span class="s">AKS</span><span class="nv"> </span><span class="s">credentials"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">kubelogin remove-cache-dir</span>
        <span class="s">az aks get-credentials \</span>
          <span class="s">--name aks-$ \</span>
          <span class="s">--resource-group aks-rg-$ \</span>
          <span class="s">--overwrite-existing</span>
        <span class="s">kubelogin convert-kubeconfig -l azurecli</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Install</span><span class="nv"> </span><span class="s">flux-operator</span><span class="nv"> </span><span class="s">CLI"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">VERSION=$(yq '.image.tag' ./clusters/$/flux-operator-values.yaml)</span>
        <span class="s">VERSION_NO_V="${VERSION#v}"</span>
        <span class="s">curl -s -O -L "https://github.com/controlplaneio-fluxcd/flux-operator/releases/download/${VERSION}/flux-operator_${VERSION_NO_V}_linux_amd64.tar.gz"</span>
        <span class="s">curl -s -O -L "https://github.com/controlplaneio-fluxcd/flux-operator/releases/download/${VERSION}/flux-operator_${VERSION_NO_V}_checksums.txt"</span>
        <span class="s">grep "flux-operator_${VERSION_NO_V}_linux_amd64.tar.gz" flux-operator_${VERSION_NO_V}_checksums.txt | sha256sum --check</span>
        <span class="s">tar -xzf flux-operator_${VERSION_NO_V}_linux_amd64.tar.gz -C /tmp flux-operator</span>
        <span class="s">sudo mv /tmp/flux-operator /usr/local/bin/flux-operator</span>
        <span class="s">flux-operator version --client</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Validate</span><span class="nv"> </span><span class="s">FluxInstance</span><span class="nv"> </span><span class="s">on</span><span class="nv"> </span><span class="s">$"</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">INSTANCE_FILE="./clusters/$/flux-instance.yaml"</span>
        <span class="s">flux-operator build instance -f $INSTANCE_FILE</span>
        <span class="s">kubectl apply -f $INSTANCE_FILE --dry-run=server</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Apply</span><span class="nv"> </span><span class="s">FluxInstance</span><span class="nv"> </span><span class="s">on</span><span class="nv"> </span><span class="s">$"</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">and(succeeded(), not(startsWith(variables['Build.SourceBranch'], 'refs/pull')))</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">INSTANCE_FILE="./clusters/$/flux-instance.yaml"</span>
        <span class="s">APPLY_OUTPUT=$(kubectl apply -f $INSTANCE_FILE)</span>
        <span class="s">echo "$APPLY_OUTPUT"</span>

        <span class="s">if echo "$APPLY_OUTPUT" | grep -q "unchanged"; then</span>
          <span class="s">READY=$(kubectl get fluxinstance flux -n flux-system \</span>
            <span class="s">-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')</span>
          <span class="s">if [ "$READY" != "True" ]; then</span>
            <span class="s">kubectl wait fluxinstance/flux --for=condition=Ready \</span>
              <span class="s">--namespace flux-system --timeout=$</span>
          <span class="s">else</span>
            <span class="s">echo "FluxInstance already Ready ✓"</span>
          <span class="s">fi</span>
        <span class="s">else</span>
          <span class="s">kubectl wait fluxinstance/flux --for=condition=Ready \</span>
            <span class="s">--namespace flux-system --timeout=$</span>
        <span class="s">fi</span>

        <span class="s">kubectl -n flux-system get fluxinstance flux</span>
        <span class="s">kubectl -n flux-system get pods</span>

  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Cleanup</span><span class="nv"> </span><span class="s">AKS</span><span class="nv"> </span><span class="s">credentials"</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">always()</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
      <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
      <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
      <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">kubelogin remove-cache-dir</span>
        <span class="s">kubectl config delete-context aks-$ 2&gt;/dev/null || true</span>
        <span class="s">kubectl config delete-cluster aks-$ 2&gt;/dev/null || true</span>
        <span class="s">echo "Credentials cleaned up ✓"</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/common-tools.yaml</code></strong> — installs every tool the pipeline needs with SHA256 checksum verification on each download:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">kubectlVersion</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">v1.32.9"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">kubeLoginVersion</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">v0.2.10"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">yqVersion</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">v4.52.4"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">trivyVersion</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0.69.3"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">cosignVersion</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">v2.6.1"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">craneVersion</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">v0.20.3"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">helmVersion</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">v3.18.4"</span>

<span class="na">steps</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">KubectlInstaller@0</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Install kubectl $</span>
    <span class="na">inputs</span><span class="pi">:</span>
      <span class="na">kubectlVersion</span><span class="pi">:</span> <span class="s">$</span>

  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">set -euo pipefail</span>
      <span class="s">curl -fsSL "https://github.com/Azure/kubelogin/releases/download/$/kubelogin-linux-amd64.zip" -o kubelogin-linux-amd64.zip</span>
      <span class="s">curl -fsSL "https://github.com/Azure/kubelogin/releases/download/$/kubelogin-linux-amd64.zip.sha256" -o kubelogin-linux-amd64.zip.sha256</span>
      <span class="s">sha256sum --check kubelogin-linux-amd64.zip.sha256</span>
      <span class="s">unzip kubelogin-linux-amd64.zip &amp;&amp; sudo mv bin/linux_amd64/kubelogin /usr/local/bin</span>
      <span class="s">kubelogin --version</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Install kubelogin $</span>

  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">set -euo pipefail</span>
      <span class="s">curl -fsSL "https://github.com/aquasecurity/trivy/releases/download/v$/trivy_$_Linux-64bit.tar.gz" -o trivy.tar.gz</span>
      <span class="s">curl -fsSL "https://github.com/aquasecurity/trivy/releases/download/v$/trivy_$_checksums.txt" -o trivy_checksums.txt</span>
      <span class="s">grep "trivy_$_Linux-64bit.tar.gz" trivy_checksums.txt | sha256sum --check</span>
      <span class="s">tar -xzf trivy.tar.gz trivy &amp;&amp; sudo mv trivy /usr/local/bin/trivy</span>
      <span class="s">trivy -v</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Install Trivy $</span>

  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">set -euo pipefail</span>
      <span class="s">curl -fsSL "https://github.com/mikefarah/yq/releases/download/$/yq_linux_amd64" -o yq_linux_amd64</span>
      <span class="s">curl -fsSL "https://github.com/mikefarah/yq/releases/download/$/checksums-bsd" -o yq_checksums-bsd</span>
      <span class="s">grep "^SHA256 (yq_linux_amd64)" yq_checksums-bsd | awk '{print $NF "  yq_linux_amd64"}' | sha256sum --check</span>
      <span class="s">sudo mv yq_linux_amd64 /usr/local/bin/yq &amp;&amp; sudo chmod +x /usr/local/bin/yq</span>
      <span class="s">yq --version</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Install yq $</span>

  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">set -euo pipefail</span>
      <span class="s">curl -fsSL "https://github.com/google/go-containerregistry/releases/download/$/go-containerregistry_Linux_x86_64.tar.gz" -o crane.tar.gz</span>
      <span class="s">curl -fsSL "https://github.com/google/go-containerregistry/releases/download/$/checksums.txt" -o crane_checksums.txt</span>
      <span class="s">grep "go-containerregistry_Linux_x86_64.tar.gz" crane_checksums.txt | sha256sum --check</span>
      <span class="s">tar -xzf crane.tar.gz crane &amp;&amp; sudo mv crane /usr/local/bin/crane</span>
      <span class="s">crane version</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Install crane $</span>

  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">set -euo pipefail</span>
      <span class="s">curl -fsSL "https://get.helm.sh/helm-$-linux-amd64.tar.gz" -o helm.tar.gz</span>
      <span class="s">curl -fsSL "https://get.helm.sh/helm-$-linux-amd64.tar.gz.sha256sum" -o helm.tar.gz.sha256sum</span>
      <span class="s">sha256sum --check helm.tar.gz.sha256sum</span>
      <span class="s">tar -xzf helm.tar.gz linux-amd64/helm &amp;&amp; sudo mv linux-amd64/helm /usr/local/bin/helm</span>
      <span class="s">helm version</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Install Helm $</span>

  <span class="pi">-</span> <span class="na">bash</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">set -euo pipefail</span>
      <span class="s">curl -fsSL "https://github.com/sigstore/cosign/releases/download/$/cosign-linux-amd64" -o cosign-linux-amd64</span>
      <span class="s">curl -fsSL "https://github.com/sigstore/cosign/releases/download/$/cosign_checksums.txt" -o cosign_checksums.txt</span>
      <span class="s">grep "cosign-linux-amd64$" cosign_checksums.txt | sha256sum --check</span>
      <span class="s">sudo mv cosign-linux-amd64 /usr/local/bin/cosign &amp;&amp; sudo chmod +x /usr/local/bin/cosign</span>
      <span class="s">cosign version</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Install Cosign $</span>
</code></pre></div></div>

<h2 id="systems-validation-pipeline">Systems Validation Pipeline</h2>

<p>There are actually two pipelines. The main <code class="language-plaintext highlighter-rouge">azure-pipelines.yaml</code> handles Flux infrastructure (operator, FluxInstance, images). A separate <code class="language-plaintext highlighter-rouge">azure-pipelines-systems.yaml</code> validates system manifests on PRs.</p>

<p>When someone opens a PR that touches <code class="language-plaintext highlighter-rouge">clusters/&lt;env&gt;/systems/</code>, this pipeline runs <code class="language-plaintext highlighter-rouge">kubectl apply --dry-run=server</code> against the live cluster for the target environment. Server-side dry-run is important — it catches errors that client-side validation misses, like referencing a CRD that does not exist in the cluster or a resource that would conflict with an existing one.</p>

<p>Only the stage matching the PR target branch runs. A PR targeting <code class="language-plaintext highlighter-rouge">dev</code> validates <code class="language-plaintext highlighter-rouge">clusters/dev/systems/</code> only — not experimental or prod.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># azure-pipelines-systems.yaml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">k8s-gitops-systems-validate</span>

<span class="na">trigger</span><span class="pi">:</span> <span class="s">none</span> <span class="c1"># no push trigger — PR only via branch policy</span>

<span class="na">pool</span><span class="pi">:</span> <span class="s">my-linux-agents</span>

<span class="na">stages</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s">ValidateExperimental</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Validate systems manifests in experimental</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">eq(variables['System.PullRequest.TargetBranch'], 'refs/heads/experimental')</span>
    <span class="na">jobs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">ci-cd-templates/systems-validate.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s2">"</span><span class="s">experimental"</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s2">"</span><span class="s">k8s-rbac-experimental-ado-sc"</span>

  <span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s">ValidateDev</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Validate systems manifests in dev</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">eq(variables['System.PullRequest.TargetBranch'], 'refs/heads/dev')</span>
    <span class="na">jobs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">ci-cd-templates/systems-validate.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s2">"</span><span class="s">dev"</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s2">"</span><span class="s">k8s-rbac-dev-ado-sc"</span>

  <span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s">ValidateProd</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Validate systems manifests in prod</span>
    <span class="na">condition</span><span class="pi">:</span> <span class="s">eq(variables['System.PullRequest.TargetBranch'], 'refs/heads/master')</span>
    <span class="na">jobs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">ci-cd-templates/systems-validate.yaml</span>
        <span class="na">parameters</span><span class="pi">:</span>
          <span class="na">environment</span><span class="pi">:</span> <span class="s2">"</span><span class="s">prod"</span>
          <span class="na">serviceConnection</span><span class="pi">:</span> <span class="s2">"</span><span class="s">k8s-rbac-prod-ado-sc"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">trigger: none</code> is intentional — this pipeline is attached as a branch policy in Azure DevOps, not triggered by a push. It only runs when a PR is opened or updated.</p>

<p>Flux does <strong>not</strong> trigger from changes to <code class="language-plaintext highlighter-rouge">clusters/*/systems/</code>. Those are reconciled directly by Flux on its own interval (every 1–5 minutes depending on the resource). The systems pipeline only validates on PRs — it has no push trigger.</p>

<p><strong><code class="language-plaintext highlighter-rouge">ci-cd-templates/systems-validate.yaml</code></strong> — fetches cluster credentials, runs <code class="language-plaintext highlighter-rouge">kubectl apply --dry-run=server</code> against all files in <code class="language-plaintext highlighter-rouge">clusters/&lt;env&gt;/systems/</code>, then cleans up credentials:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">parameters</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">environment</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">serviceConnection</span>
    <span class="na">default</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">ValidateSystemsManifests</span>
    <span class="na">displayName</span><span class="pi">:</span> <span class="s">Validate systems manifests in $ cluster</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">template</span><span class="pi">:</span> <span class="s">common-tools.yaml</span>

      <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
        <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Get</span><span class="nv"> </span><span class="s">AKS</span><span class="nv"> </span><span class="s">credentials"</span>
        <span class="na">inputs</span><span class="pi">:</span>
          <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
          <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
          <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
          <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">kubelogin remove-cache-dir</span>
            <span class="s">az aks get-credentials \</span>
              <span class="s">--name aks-$ \</span>
              <span class="s">--resource-group aks-rg-$ \</span>
              <span class="s">--overwrite-existing</span>
            <span class="s">kubelogin convert-kubeconfig -l azurecli</span>

      <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
        <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Dry-run</span><span class="nv"> </span><span class="s">systems</span><span class="nv"> </span><span class="s">manifests</span><span class="nv"> </span><span class="s">on</span><span class="nv"> </span><span class="s">$"</span>
        <span class="na">inputs</span><span class="pi">:</span>
          <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
          <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
          <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
          <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">set -euo pipefail</span>
            <span class="s">SYSTEMS_PATH="./clusters/$/systems"</span>
            <span class="s">FILES=$(find "$SYSTEMS_PATH" -name "*.yaml" | sort)</span>

            <span class="s">if [ -z "$FILES" ]; then</span>
              <span class="s">echo "No system files found — nothing to validate"</span>
              <span class="s">exit 0</span>
            <span class="s">fi</span>

            <span class="s">echo "Files to validate:"</span>
            <span class="s">echo "$FILES" | while read -r f; do echo "  → $f"; done</span>

            <span class="s">kubectl apply -f "$SYSTEMS_PATH" --dry-run=server</span>
            <span class="s">echo "Validation passed ✓"</span>

      <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
        <span class="na">displayName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Cleanup</span><span class="nv"> </span><span class="s">AKS</span><span class="nv"> </span><span class="s">credentials"</span>
        <span class="na">condition</span><span class="pi">:</span> <span class="s">always()</span>
        <span class="na">inputs</span><span class="pi">:</span>
          <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s2">"</span><span class="s">$"</span>
          <span class="na">scriptType</span><span class="pi">:</span> <span class="s">bash</span>
          <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s">inlineScript</span>
          <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">kubelogin remove-cache-dir</span>
            <span class="s">kubectl config delete-context aks-$ 2&gt;/dev/null || true</span>
            <span class="s">kubectl config delete-cluster aks-$ 2&gt;/dev/null || true</span>
            <span class="s">echo "Credentials cleaned up ✓"</span>
</code></pre></div></div>

<h2 id="onboarding-systems--three-patterns">Onboarding Systems — Three Patterns</h2>

<p>The platform supports three onboarding patterns. The platform team’s registration step in <code class="language-plaintext highlighter-rouge">k8s-gitops</code> is identical for all three. The difference is entirely in what the system team places in their own repository.</p>

<p><strong>Platform team registration</strong> — one file per system, per environment:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># clusters/&lt;env&gt;/systems/&lt;system&gt;.yaml</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">source.toolkit.fluxcd.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">GitRepository</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">k8s-&lt;system&gt;</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">interval</span><span class="pi">:</span> <span class="s">1m</span>
  <span class="na">url</span><span class="pi">:</span> <span class="s">https://dev.azure.com/my-org/my-project/_git/k8s-&lt;system&gt;</span>
  <span class="na">ref</span><span class="pi">:</span>
    <span class="na">branch</span><span class="pi">:</span> <span class="s">&lt;env&gt;</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">azure</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">kustomize.toolkit.fluxcd.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Kustomization</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">&lt;system&gt;</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">interval</span><span class="pi">:</span> <span class="s">5m</span>
  <span class="na">path</span><span class="pi">:</span> <span class="s">./environments/&lt;env&gt;/flux</span>
  <span class="na">prune</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">sourceRef</span><span class="pi">:</span>
    <span class="na">kind</span><span class="pi">:</span> <span class="s">GitRepository</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">k8s-&lt;system&gt;</span>
</code></pre></div></div>

<p>Flux watches <code class="language-plaintext highlighter-rouge">clusters/&lt;env&gt;/systems/</code> in the <code class="language-plaintext highlighter-rouge">k8s-gitops</code> repo. When this file is merged, Flux picks it up on its next reconciliation interval, starts watching the system repo at the specified branch and path, and applies whatever it finds there.</p>

<h3 id="pattern-a--system-owns-its-own-chart-and-helmrelease">Pattern A — System Owns Its Own Chart and HelmRelease</h3>

<p>Used for platform components (nginx, cert-manager, kured, etc.) and application systems with custom charts. Each system repo co-locates its Helm chart and <code class="language-plaintext highlighter-rouge">HelmRelease</code>.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k8s-&lt;system&gt;/
├── &lt;chart-dir&gt;/                   # Helm chart co-located in the same repo
│   ├── Chart.yaml
│   ├── templates/
│   └── values.yaml                # base values
└── environments/
    ├── experimental/
    │   ├── values.yaml            # env-specific overrides
    │   └── flux/
    │       └── helmrelease.yaml   # Flux reconciles this path
    ├── dev/
    │   ├── values.yaml
    │   └── flux/
    │       └── helmrelease.yaml
    └── prod/
        ├── values.yaml
        └── flux/
            └── helmrelease.yaml
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">HelmRelease</code> in each environment references the chart by relative path in the same <code class="language-plaintext highlighter-rouge">GitRepository</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">helm.toolkit.fluxcd.io/v2</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">HelmRelease</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">&lt;system&gt;</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">interval</span><span class="pi">:</span> <span class="s">10m</span>
  <span class="na">targetNamespace</span><span class="pi">:</span> <span class="s">&lt;system&gt;</span>
  <span class="na">install</span><span class="pi">:</span>
    <span class="na">createNamespace</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">upgrade</span><span class="pi">:</span>
    <span class="na">cleanupOnFail</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">chart</span><span class="pi">:</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">chart</span><span class="pi">:</span> <span class="s">./&lt;chart-dir&gt;</span>
      <span class="na">reconcileStrategy</span><span class="pi">:</span> <span class="s">Revision</span>
      <span class="na">sourceRef</span><span class="pi">:</span>
        <span class="na">kind</span><span class="pi">:</span> <span class="s">GitRepository</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">k8s-&lt;system&gt;</span>
        <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
      <span class="na">valuesFiles</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">./&lt;chart-dir&gt;/values.yaml</span>
        <span class="pi">-</span> <span class="s">./environments/&lt;env&gt;/values.yaml</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">HelmRelease</code> always lives in <code class="language-plaintext highlighter-rouge">flux-system</code>. The <code class="language-plaintext highlighter-rouge">targetNamespace</code> tells <code class="language-plaintext highlighter-rouge">helm-controller</code> where to actually deploy the chart’s resources. Flux auto-creates an internal <code class="language-plaintext highlighter-rouge">HelmChart</code> object in <code class="language-plaintext highlighter-rouge">flux-system</code> — you never manage that directly.</p>

<h3 id="pattern-b--central-shared-chart-system-owns-only-values">Pattern B — Central Shared Chart, System Owns Only Values</h3>

<p>Pattern B is the <strong>standard pattern for business applications</strong>. The platform team maintains a single central Helm chart repo that encapsulates everything a well-behaved application deployment needs: namespace creation, RBAC, NetworkPolicy, ResourceQuota, LimitRange, and default ServiceAccount annotations. Application teams supply only a <code class="language-plaintext highlighter-rouge">HelmRelease</code> pointing at that central chart with their own values — they never write RBAC YAML, never define quotas, and never touch chart internals.</p>

<p>The platform team registers the central chart repo once in <code class="language-plaintext highlighter-rouge">k8s-gitops</code> as a shared <code class="language-plaintext highlighter-rouge">GitRepository</code>, then each business application gets its own <code class="language-plaintext highlighter-rouge">GitRepository</code> + <code class="language-plaintext highlighter-rouge">Kustomization</code> registration as usual. In the application repo the <code class="language-plaintext highlighter-rouge">HelmRelease</code> references the central chart source:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">helm.toolkit.fluxcd.io/v2</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">HelmRelease</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">&lt;system&gt;</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">interval</span><span class="pi">:</span> <span class="s">10m</span>
  <span class="na">targetNamespace</span><span class="pi">:</span> <span class="s">&lt;system&gt;</span>
  <span class="na">chart</span><span class="pi">:</span>
    <span class="na">spec</span><span class="pi">:</span>
      <span class="na">chart</span><span class="pi">:</span> <span class="s">./platform-app-chart</span> <span class="c1"># chart from the central repo</span>
      <span class="na">sourceRef</span><span class="pi">:</span>
        <span class="na">kind</span><span class="pi">:</span> <span class="s">GitRepository</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">k8s-platform-charts</span> <span class="c1"># central chart GitRepository, registered once</span>
        <span class="na">namespace</span><span class="pi">:</span> <span class="s">flux-system</span>
  <span class="na">values</span><span class="pi">:</span>
    <span class="na">replicaCount</span><span class="pi">:</span> <span class="m">2</span>
    <span class="na">image</span><span class="pi">:</span>
      <span class="na">repository</span><span class="pi">:</span> <span class="s">myacr.azurecr.io/my-org/&lt;system&gt;</span>
      <span class="na">tag</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1.0.0"</span>
    <span class="na">aadGroupId</span><span class="pi">:</span> <span class="s2">"</span><span class="s">&lt;team-aad-group-id&gt;"</span> <span class="c1"># drives RoleBinding generation inside the chart</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">aadGroupId</code> value is the key pattern — the central chart uses it to generate a <code class="language-plaintext highlighter-rouge">RoleBinding</code> for the team’s Azure AD group, scoped to their namespace. Teams get access to their own namespace without ever writing a RBAC manifest. Baseline security posture (NetworkPolicy, LimitRange, default quotas) is inherited from the chart automatically. It enforces baseline security posture across all application teams without manual steps or platform team involvement per namespace.</p>

<h4 id="platform-skus--right-sized-resource-quotas">Platform SKUs — Right-Sized Resource Quotas</h4>

<p>One extension of the central chart model is <strong>platform SKUs</strong> — a set of pre-defined resource profiles that teams choose from when onboarding. Instead of negotiating ResourceQuota and LimitRange values per team, the platform exposes a small menu of named tiers:</p>

<table>
  <thead>
    <tr>
      <th>SKU</th>
      <th>Max Pods</th>
      <th>CPU request → limit</th>
      <th>Memory request → limit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sku-small</code></td>
      <td>5</td>
      <td>100m → 1</td>
      <td>128Mi → 1Gi</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sku-medium</code></td>
      <td>15</td>
      <td>200m → 2</td>
      <td>256Mi → 4Gi</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sku-large</code></td>
      <td>30</td>
      <td>500m → 4</td>
      <td>512Mi → 8Gi</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sku-batch</code></td>
      <td>20</td>
      <td>500m → 8</td>
      <td>1Gi → 16Gi</td>
    </tr>
  </tbody>
</table>

<p>Each SKU is a separate Helm chart (or a named values preset inside the central chart). A team references their chosen SKU alongside their app <code class="language-plaintext highlighter-rouge">HelmRelease</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">values</span><span class="pi">:</span>
  <span class="na">replicaCount</span><span class="pi">:</span> <span class="m">2</span>
  <span class="na">image</span><span class="pi">:</span>
    <span class="na">repository</span><span class="pi">:</span> <span class="s">myacr.azurecr.io/my-org/&lt;system&gt;</span>
    <span class="na">tag</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1.0.0"</span>
  <span class="na">aadGroupId</span><span class="pi">:</span> <span class="s2">"</span><span class="s">&lt;team-aad-group-id&gt;"</span>
  <span class="na">sku</span><span class="pi">:</span> <span class="s">sku-medium</span> <span class="c1"># injects ResourceQuota + LimitRange for this tier</span>
</code></pre></div></div>

<p>The platform team owns the SKU definitions centrally — if a tier needs tuning, one chart change propagates to every team using that SKU on the next reconciliation. Teams never touch quota YAML and can request a larger SKU via a PR comment rather than a platform ticket.</p>

<h3 id="pattern-c--plain-yaml-or-kustomize-exception-only">Pattern C — Plain YAML or Kustomize (Exception Only)</h3>

<p>Pattern C is <strong>not a standard onboarding path</strong> — it is a deliberate escape hatch for the small category of resources that genuinely cannot fit inside a Helm chart: CRDs, ClusterRoles, cross-namespace objects, or anything that must exist before any chart can be installed. The Kustomization points at a path containing plain YAML files or a <code class="language-plaintext highlighter-rouge">kustomization.yaml</code> overlay, and <code class="language-plaintext highlighter-rouge">kustomize-controller</code> applies them directly.</p>

<p>If you find yourself reaching for Pattern C for a regular application workload, that is a signal something is missing from the central chart — fix the chart rather than bypassing it. Pattern C should be rare, reviewed carefully, and always additive. It cannot override what a Pattern B chart already manages, and any Pattern C registration requires explicit platform team approval before merging to <code class="language-plaintext highlighter-rouge">k8s-gitops</code>.</p>

<h3 id="choosing-a-pattern">Choosing a Pattern</h3>

<table>
  <thead>
    <tr>
      <th>Situation</th>
      <th>Pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>System owns a custom Helm chart</td>
      <td>A</td>
    </tr>
    <tr>
      <td>System uses a platform-provided shared chart</td>
      <td>B</td>
    </tr>
    <tr>
      <td>Exceptional resources only (CRDs, ClusterRoles, cross-namespace)</td>
      <td>C</td>
    </tr>
  </tbody>
</table>

<h2 id="business-application-ci-pipeline">Business Application CI Pipeline</h2>

<p>The <code class="language-plaintext highlighter-rouge">k8s-gitops</code> repo and the Flux infrastructure pipeline are entirely separate from how business applications build and deliver their images. Each application team owns their own Azure DevOps pipeline in their own repo — the platform only defines what must happen before an image is allowed into the cluster.</p>

<p>The contract is simple: every image that lands in production must be scanned, pushed to the private ACR, and signed. How the pipeline is structured internally is up to the team, but these three steps are non-negotiable:</p>

<ol>
  <li><strong>Build or mirror, then scan with Trivy</strong> — application images are typically built from source in the team’s own pipeline. Third-party images the application depends on (databases, sidecars, off-the-shelf tools) are mirrored from public registries using <code class="language-plaintext highlighter-rouge">crane copy</code>. In both cases the image is scanned for HIGH and CRITICAL vulnerabilities before it is pushed to ACR. The pipeline fails if any are found.</li>
  <li><strong>Push to private ACR</strong> — all images go to the private registry. <code class="language-plaintext highlighter-rouge">HelmRelease</code> values and manifests always reference <code class="language-plaintext highlighter-rouge">myacr.azurecr.io/...</code>, never a public registry directly.</li>
  <li><strong>Sign with Cosign</strong> — the image is signed using the shared Azure Key Vault key after pushing. Admission controls in the cluster can verify the signature before allowing the workload to run.</li>
</ol>

<p>This is where Pattern B closes the loop neatly. The team updates the <code class="language-plaintext highlighter-rouge">image.tag</code> value in their <code class="language-plaintext highlighter-rouge">HelmRelease</code> after a successful pipeline run, merges to the environment branch, and Flux picks up the change — no pipeline needs cluster credentials, no <code class="language-plaintext highlighter-rouge">kubectl</code> is run from CI. The pipeline’s only job is to produce a trusted, signed image in ACR. Flux handles the rest.</p>

<h2 id="switching-from-push-pipelines-to-gitops">Switching from Push Pipelines to GitOps</h2>

<p>Before GitOps the deployment model looked like most traditional CI/CD setups: pipelines authenticated to the cluster with a service principal and pushed changes in with <code class="language-plaintext highlighter-rouge">kubectl apply</code>. It worked, but over time the friction became hard to ignore:</p>

<ul>
  <li><strong>Credentials in the pipeline</strong> — every environment required a service connection with cluster-admin or broad RBAC permissions stored in the CI/CD platform. Rotation was manual, scope was wider than needed.</li>
  <li><strong>No drift detection</strong> — if someone applied a change directly to the cluster, the pipeline had no idea. The next pipeline run would overwrite it, or not, depending on whether that path was touched. There was no continuous reconciliation.</li>
  <li><strong>Deployment only on trigger</strong> — the cluster state was only as fresh as the last pipeline run. A failed pipeline meant nothing was deployed, with no retry or self-healing.</li>
  <li><strong>No supply chain controls on images</strong> — images were mirrored or built in the pipeline but without vulnerability scanning or signing. There was no enforcement gate preventing an unscanned or unsigned image from reaching the cluster.</li>
  <li><strong>Pipeline as the gatekeeper</strong> — every change had to flow through a pipeline, but the pipeline could not tell you whether the cluster had drifted from what it last applied.</li>
</ul>

<p>The GitOps model inverts this. The cluster reaches out to Git, not the other way around. Credentials stay inside the cluster (and in this setup are federated via Workload Identity — no secrets at all). The operator reconciles continuously, so a drift is corrected within minutes without any human or pipeline intervention. Because everything is declared in Git and reviewed via pull requests, you get a full audit trail for free.</p>

<p>The shift is not just operational; it changes how teams think about deployments. Instead of “run the pipeline to deploy”, the mental model becomes “merge to the environment branch and Flux will pick it up”. The pipeline still exists, but its job is narrower: validate the infrastructure layer (Flux Operator, FluxInstance), mirror and sign images, and ensure the config is correct before it lands in Git. Day-to-day system changes bypass the pipeline entirely — Flux handles them on its own interval.</p>

<h2 id="upgrading-flux">Upgrading Flux</h2>

<p>Upgrading Flux in this setup is a four-step process:</p>

<ol>
  <li>
    <p><strong>Generate the updated image list</strong> using the helper script:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./scripts/flux-images.sh v2.9.0 v0.46.0 ./clusters/experimental/flux-operator-values.yaml
</code></pre></div>    </div>

    <p>The script uses the <code class="language-plaintext highlighter-rouge">flux</code> CLI to resolve controller images for the requested version and reads the operator image repository from the values file. Output looks like this:</p>

    <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>======================================================================
Add to azure-pipelines.yaml containerImages:
----------------------------------------------------------------------
  - myacr.azurecr.io/controlplaneio-fluxcd/flux-operator:v0.46.0
  - ghcr.io/fluxcd/helm-controller:v1.2.0
  - ghcr.io/fluxcd/image-automation-controller:v0.40.0
  - ghcr.io/fluxcd/image-reflector-controller:v0.34.0
  - ghcr.io/fluxcd/kustomize-controller:v1.5.0
  - ghcr.io/fluxcd/notification-controller:v1.5.0
  - ghcr.io/fluxcd/source-controller:v1.5.0
======================================================================
</code></pre></div>    </div>

    <p>Here is the full script (<code class="language-plaintext highlighter-rouge">scripts/flux-images.sh</code>):</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># Generates the containerImages list for azure-pipelines.yaml when upgrading</span>
<span class="c"># Flux controllers or the Flux Operator.</span>
<span class="c">#</span>
<span class="c"># Usage:</span>
<span class="c">#   ./flux-images.sh &lt;FLUX_VERSION&gt; &lt;OPERATOR_VERSION&gt; [VALUES_FILE]</span>
<span class="c">#</span>
<span class="c"># Prerequisites: flux CLI, yq</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">FLUX_VERSION</span><span class="o">=</span><span class="k">${</span><span class="nv">1</span><span class="k">:-</span><span class="s2">""</span><span class="k">}</span>
<span class="nv">OPERATOR_VERSION</span><span class="o">=</span><span class="k">${</span><span class="nv">2</span><span class="k">:-</span><span class="s2">""</span><span class="k">}</span>
<span class="nv">DEFAULT_VALUES</span><span class="o">=</span><span class="k">${</span><span class="nv">3</span><span class="k">:-</span><span class="s2">"values.yaml"</span><span class="k">}</span>

<span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$FLUX_VERSION</span><span class="s2">"</span> <span class="o">]]</span>     <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">"ERROR: FLUX_VERSION required (e.g. v2.9.0)"</span> <span class="o">&gt;</span>&amp;2<span class="p">;</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="o">}</span>
<span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$OPERATOR_VERSION</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">"ERROR: OPERATOR_VERSION required (e.g. v0.46.0)"</span> <span class="o">&gt;</span>&amp;2<span class="p">;</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="o">}</span>
<span class="o">[[</span> <span class="o">!</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$DEFAULT_VALUES</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">"ERROR: Values file '</span><span class="nv">$DEFAULT_VALUES</span><span class="s2">' not found."</span> <span class="o">&gt;</span>&amp;2<span class="p">;</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="o">}</span>
<span class="nb">command</span> <span class="nt">-v</span> flux &amp;&gt;/dev/null   <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">"ERROR: flux CLI not installed."</span> <span class="o">&gt;</span>&amp;2<span class="p">;</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="o">}</span>
<span class="nb">command</span> <span class="nt">-v</span> yq   &amp;&gt;/dev/null   <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">"ERROR: yq not installed."</span> <span class="o">&gt;</span>&amp;2<span class="p">;</span> <span class="nb">exit </span>1<span class="p">;</span> <span class="o">}</span>

<span class="nv">OPERATOR_REPO</span><span class="o">=</span><span class="si">$(</span>yq <span class="s1">'.image.repository'</span> <span class="s2">"</span><span class="nv">$DEFAULT_VALUES</span><span class="s2">"</span><span class="si">)</span>
<span class="nv">OPERATOR_IMAGE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$OPERATOR_REPO</span><span class="s2">:</span><span class="nv">$OPERATOR_VERSION</span><span class="s2">"</span>

<span class="nv">FLUX_IMAGES</span><span class="o">=</span><span class="si">$(</span>flux <span class="nb">install</span> <span class="se">\</span>
  <span class="nt">--version</span><span class="o">=</span><span class="s2">"</span><span class="nv">$FLUX_VERSION</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--components</span><span class="o">=</span>source-controller,helm-controller,kustomize-controller,notification-controller <span class="se">\</span>
  <span class="nt">--components-extra</span><span class="o">=</span>image-reflector-controller,image-automation-controller <span class="se">\</span>
  <span class="nt">--export</span> <span class="se">\</span>
  | <span class="nb">grep</span> <span class="s1">'image: ghcr.io/fluxcd/'</span> <span class="se">\</span>
  | <span class="nb">awk</span> <span class="s1">'{print $2}'</span> <span class="se">\</span>
  | <span class="nb">sort</span> <span class="nt">-u</span><span class="si">)</span>

<span class="nb">echo</span> <span class="s2">"======================================================================"</span>
<span class="nb">echo</span> <span class="s2">"Add to azure-pipelines.yaml containerImages:"</span>
<span class="nb">echo</span> <span class="s2">"----------------------------------------------------------------------"</span>
<span class="nb">echo</span> <span class="s2">"  - </span><span class="nv">$OPERATOR_IMAGE</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="s2">"</span><span class="nv">$FLUX_IMAGES</span><span class="s2">"</span> | <span class="k">while </span><span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> <span class="nt">-r</span> IMAGE<span class="p">;</span> <span class="k">do </span><span class="nb">echo</span> <span class="s2">"  - </span><span class="nv">$IMAGE</span><span class="s2">"</span><span class="p">;</span> <span class="k">done
</span><span class="nb">echo</span> <span class="s2">"======================================================================"</span>
<span class="nb">echo</span> <span class="s2">""</span>
<span class="nb">echo</span> <span class="s2">"Next steps:"</span>
<span class="nb">echo</span> <span class="s2">"  1. Copy the image list into azure-pipelines.yaml"</span>
<span class="nb">echo</span> <span class="s2">"  2. Update spec.distribution.version in clusters/*/flux-instance.yaml to </span><span class="nv">$FLUX_VERSION</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="s2">"  3. Update the operator image tag in clusters/*/flux-operator-values.yaml to </span><span class="nv">$OPERATOR_VERSION</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="s2">"  4. git commit + push + open PR to experimental first"</span>
</code></pre></div>    </div>

    <p>Requires the <code class="language-plaintext highlighter-rouge">flux</code> CLI and <code class="language-plaintext highlighter-rouge">yq</code> to be installed locally.</p>
  </li>
  <li>
    <p><strong>Update <code class="language-plaintext highlighter-rouge">azure-pipelines.yaml</code></strong> — replace the <code class="language-plaintext highlighter-rouge">containerImages</code> list for all three environments with the output above.</p>
  </li>
  <li>
    <p><strong>Update <code class="language-plaintext highlighter-rouge">spec.distribution.version</code></strong> in each <code class="language-plaintext highlighter-rouge">clusters/&lt;env&gt;/flux-instance.yaml</code>.</p>
  </li>
  <li>
    <p><strong>Open a PR to <code class="language-plaintext highlighter-rouge">experimental</code></strong> — validate, merge, then promote to <code class="language-plaintext highlighter-rouge">dev</code> and finally <code class="language-plaintext highlighter-rouge">master</code>.</p>
  </li>
</ol>

<p>The Flux Operator handles the rollout. You do not interact with the cluster directly. The pipeline validates the configuration before it touches anything, and the FluxInstance apply step waits for the <code class="language-plaintext highlighter-rouge">Ready</code> condition before the pipeline marks success.</p>

<h2 id="key-lessons">Key Lessons</h2>

<p><strong>Use the Flux Operator.</strong> <code class="language-plaintext highlighter-rouge">flux bootstrap</code> was the right tool for getting started quickly, but the Flux Operator is the right tool for operating Flux long-term. Declarative upgrades, version-controlled configuration, and no generated YAML to maintain.</p>

<p><strong>Mirror images to private ACR.</strong> Public registries have rate limits and availability dependencies. Mirroring with <code class="language-plaintext highlighter-rouge">crane copy</code> preserves digests, which means Cosign signatures remain valid. Trivy scanning before the copy gives you a vulnerability gate. All three together — mirror, scan, sign — is a practical and automatable supply chain control.</p>

<p><strong>Server-side dry-run on PRs is worth it.</strong> Client-side validation misses schema errors for custom resources and referential integrity issues. Server-side dry-run catches these before anything reaches the cluster.</p>

<p><strong><code class="language-plaintext highlighter-rouge">prune: true</code> on Kustomizations.</strong> This ensures that when you remove a manifest from Git, the corresponding resource is deleted from the cluster. Without it, removed resources silently linger and you lose the “Git is the source of truth” property.</p>

<p><strong>Workload Identity over credentials.</strong> Setting up federated credentials takes a bit more upfront effort than creating a PAT, but you eliminate a class of credential management problems entirely. No rotation, no expiry, no secrets stored anywhere.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Building a GitOps platform that teams actually want to use comes down to making the happy path easy and the unsafe path hard. In this setup the platform team maintains the Flux infrastructure layer and provides the system registration pattern. System teams own their repositories, their charts, their release cadence, and their values. They do not need platform team approval to deploy a new version of their application.</p>

<p>The pipeline handles the trust model: images are scanned before they enter the private registry, signed after they arrive, and verified before they are deployed. Pull requests are validated with server-side dry-run before merge. Flux handles reconciliation continuously, not just on pipeline triggers.</p>

<p>If you are looking to build a similar setup or are in the middle of migrating from a more manual approach, I hope this walkthrough is useful. Although the examples here use Azure DevOps and AKS, the approach is broadly portable — Flux supports GitHub, GitLab, and Gitea natively via the <code class="language-plaintext highlighter-rouge">provider</code> field in <code class="language-plaintext highlighter-rouge">GitRepository</code>, and the pipeline layer is standard YAML that maps directly to GitHub Actions or GitLab CI. The Flux side of the setup does not change at all. Feel free to reach out if you have questions — I’m happy to dig into any of the details.</p>

<p>Thank you for reading!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Kubernetes" /><category term="GitOps" /><category term="Flux" /><category term="Helm" /><category term="Azure" /><category term="AKS" /><category term="DevOps" /><category term="Azure DevOps" /><category term="ACR" /><category term="CI/CD" /><category term="Trivy" /><category term="Cosign" /><category term="Security" /><category term="Workload Identity" /><category term="Kustomize" /><category term="HelmRelease" /><category term="FluxCD" /><category term="Platform Engineering" /><category term="Cloud Native" /><category term="CNCF" /><summary type="html"><![CDATA[A practical walkthrough of a production GitOps platform built on Flux CD, Flux Operator, and Helm — with Azure Workload Identity, Trivy scanning, Cosign signing, and a clean multi-environment onboarding model.]]></summary></entry><entry><title type="html">My Golden Kubestronaut Journey</title><link href="http://sysadminas.eu/Golden-Kubestronaut/" rel="alternate" type="text/html" title="My Golden Kubestronaut Journey" /><published>2026-03-01T00:00:00+00:00</published><updated>2026-03-01T00:00:00+00:00</updated><id>http://sysadminas.eu/Golden-Kubestronaut</id><content type="html" xml:base="http://sysadminas.eu/Golden-Kubestronaut/"><![CDATA[<p><img align="right" width="400" height="400" src="../assets/images/post28/1.png" /></p>

<p>Hello everyone!</p>

<p>I’m incredibly excited to share that I have just achieved the <a href="https://www.cncf.io/training/kubestronaut/">Golden Kubestronaut</a> status — the highest recognition in the CNCF certification landscape! This is a milestone I’ve been working towards for quite some time and I’m happy to share the full journey with you.</p>

<p>As you may remember from my <a href="https://sysadminas.eu/Kubestronaut/">previous post about Kubestronaut</a>, I became a Kubestronaut in September 2024 after completing all five Kubernetes certifications (CKA, CKAD, CKS, KCNA, KCSA). This time around the journey was even more ambitious — requiring me to pass all remaining CNCF certifications plus the Linux Foundation Certified System Administrator (LFCS) exam on top of what I already had.</p>

<h2 id="what-is-golden-kubestronaut">What is Golden Kubestronaut?</h2>

<p>The Golden Kubestronaut program was announced by CNCF at KubeCon + CloudNativeCon Europe in London on April 1, 2025. It is the highest tier of recognition in the CNCF ecosystem, awarded to professionals who have successfully completed every CNCF certification plus the LFCS exam. What makes this program special compared to the regular Kubestronaut status is that once you earn it, the title is yours <strong>for life</strong> — you don’t need to recertify to keep it.</p>

<p>The program requires the following certifications on top of the five Kubestronaut ones (CKA, CKAD, CKS, KCNA, KCSA):</p>

<ul>
  <li><strong>LFCS</strong> - Linux Foundation Certified System Administrator</li>
  <li><strong>PCA</strong> - Prometheus Certified Associate</li>
  <li><strong>ICA</strong> - Istio Certified Associate</li>
  <li><strong>CCA</strong> - Cilium Certified Associate</li>
  <li><strong>CAPA</strong> - Certified Argo Project Associate</li>
  <li><strong>CGOA</strong> - Certified GitOps Associate</li>
  <li><strong>CBA</strong> - Certified Backstage Associate</li>
  <li><strong>OTCA</strong> - OpenTelemetry Certified Associate</li>
  <li><strong>KCA</strong> - Kyverno Certified Associate</li>
  <li><strong>CNPA</strong> - Certified Cloud Native Platform Engineering Associate</li>
</ul>

<p>In total that is <strong>15 certifications</strong> to achieve Golden Kubestronaut status. Quite a list, but every single one of them is worth it!</p>

<h2 id="kodekloud--your-best-friend-for-this-journey">KodeKloud — Your Best Friend for This Journey</h2>

<p>Before diving into the individual exams I want to give a special shout-out to <a href="https://kodekloud.com/">KodeKloud</a>. If I had to recommend a single learning platform for the Golden Kubestronaut path, it would be KodeKloud without hesitation. They have dedicated courses for every CNCF certification covered here — complete with structured theory, hands-on labs, and mock exams. The quality is consistently high across all courses and the hands-on lab environments are particularly valuable because they let you practice in real clusters without having to set up your own infrastructure. Whether you are a complete beginner to a specific technology or just need to fill in a few gaps before an exam, KodeKloud will get you there. I used it extensively throughout my Golden Kubestronaut journey and can recommend it wholeheartedly.</p>

<h2 id="the-additional-certifications">The Additional Certifications</h2>

<p>If you have already completed your Kubestronaut journey like I did, you just need to knock out the remaining certifications. Here are my thoughts on each of them.</p>

<h3 id="lfcs--linux-foundation-certified-system-administrator">LFCS — Linux Foundation Certified System Administrator</h3>

<p><a href="https://training.linuxfoundation.org/certification/linux-foundation-certified-sysadmin-lfcs/">LFCS</a> is a hands-on, performance-based exam testing your Linux system administration skills. You can expect tasks covering Linux file system management, user and group administration, service configuration, networking, and basic security hardening. The exam is 2 hours long and has a similar feel to the CKA exam in terms of format — a terminal-based environment where you need to perform real tasks.</p>

<p>Do not underestimate this exam. Even with years of Linux experience and having passed CKA and CKS, I found LFCS to be genuinely challenging. The breadth of topics is wide and some tasks can catch you off guard if you have gaps in areas you don’t use day-to-day. Make sure to thoroughly review systemd service management, networking configuration, storage and LVM management, user/group administration, simple apache or nginx configs as well as essential commands like <code class="language-plaintext highlighter-rouge">find</code>, <code class="language-plaintext highlighter-rouge">mount</code> etc. Dedicated preparation is strongly recommended regardless of your Linux background — the KodeKloud LFCS course is a great structured option here.</p>

<h3 id="pca--prometheus-certified-associate">PCA — Prometheus Certified Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/prometheus-certified-associate/">PCA</a> is a multiple choice exam (60 questions, 90 minutes) covering the fundamentals of Prometheus — the de facto standard for monitoring in the cloud native world. The exam is split across five domains: observability concepts, Prometheus architecture and configuration, PromQL, alerting, and exporters.</p>

<p>If you use Prometheus in your daily work this exam will feel very familiar. The most important topic to master is PromQL — make sure you understand how to write queries, use functions like <code class="language-plaintext highlighter-rouge">rate()</code>, <code class="language-plaintext highlighter-rouge">increase()</code>, <code class="language-plaintext highlighter-rouge">histogram_quantile(</code>, and how to apply label selectors and aggregation operators correctly. Also pay attention to the observability concepts domain as it covers broader topics like SLOs, SLIs, and SLAs which you need to understand well. For learning resources I recommend the <a href="https://prometheus.io/docs/">Prometheus official documentation</a> and the KodeKloud PCA course.</p>

<h3 id="ica--istio-certified-associate">ICA — Istio Certified Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/istio-certified-associate-ica/">ICA</a> is a hands-on, performance-based exam similar in format to CKA and CKAD. You will be working in a real Kubernetes environment with Istio pre-installed, solving 15-20 practical tasks in 2 hours. The exam covers traffic management (35%), securing workloads (25%), troubleshooting (20%), and installation/upgrades/configuration (20%).</p>

<p>This is one of the most challenging of the additional certifications given its hands-on nature. Traffic management is the dominant topic so make sure you have solid hands-on experience with VirtualService, DestinationRule, and Gateway resources. For security, focus on mTLS and AuthorizationPolicy. You are allowed to use <a href="https://istio.io/latest/docs/">Istio documentation</a> during the exam which is a big help. Make sure you are comfortable in navigating the documentation and you know how to quickly find the resources example you need. As with CKA/CKAD I highly recommend practicing in a real cluster before attempting this one — reading alone is not enough.</p>

<h3 id="cca--cilium-certified-associate">CCA — Cilium Certified Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/cilium-certified-associate/">CCA</a> is a multiple choice exam focused on Cilium — the eBPF-based networking, observability, and security solution for Kubernetes. The exam covers Cilium architecture, network policies, Hubble observability, and cluster mesh.</p>

<p>Cilium has been gaining a lot of traction recently especially since it became a CNCF graduation project and was added as a topic to the updated CKS exam. If you have been keeping up with the Kubernetes ecosystem Cilium should not be a stranger to you. The <a href="https://docs.cilium.io/">official Cilium documentation</a> is your main resource for preparation. Yes it questions/answers multiple choice exam, but I can assure that it was most difficult multiple choice exam I have taken in my entire IT career. The questions are very detailed and require a deep understanding of Cilium concepts and features to answer correctly. Make sure to read the documentation carefully and understand how Cilium works under the hood — this is not an exam you can pass with just surface-level knowledge.</p>

<h3 id="capa--certified-argo-project-associate">CAPA — Certified Argo Project Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/certified-argo-project-associate-capa/">CAPA</a> is a multiple choice exam covering the Argo project suite — Argo Workflows, Argo CD, Argo Rollouts, and Argo Events. It is not easy, but if you have experience with Argo CD and Argo Workflows it should be manageable without to much extra preparation. The exam covers Argo architecture, core concepts, and common use cases for each of the four projects. For Argo CD focus on understanding application manifests, sync policies, and how to troubleshoot common issues. For Argo Workflows make sure you understand how to define workflows, use templates, and manage workflow execution. The <a href="https://argoproj.github.io/">official Argo documentation</a> is comprehensive and the KodeKloud CAPA course provides a great structured introduction to the entire Argo ecosystem.</p>

<h3 id="cgoa--certified-gitops-associate">CGOA — Certified GitOps Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/certified-gitops-associate-cgoa/">CGOA</a> is a beginner-level multiple choice exam focused on GitOps principles and practices. It is one of the easiest exams in the entire Golden Kubestronaut set — especially if you have done CAPA first.</p>

<p>The exam covers GitOps principles as defined by the <a href="https://opengitops.dev/">OpenGitOps</a> project, GitOps patterns, and common tooling. Understanding the four core GitOps principles and the difference between push-based and pull-based deployment models is the bulk of what you need. If you work with Argo CD or Flux regularly you may be able to pass this one with minimal extra preparation.</p>

<h3 id="cba--certified-backstage-associate">CBA — Certified Backstage Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/certified-backstage-associate-cba/">CBA</a> is a multiple choice exam covering Backstage — the open source developer portal framework originally created by Spotify and now a CNCF project. The exam covers Backstage architecture, the software catalog, TechDocs, software templates (scaffolding), and the plugin ecosystem.</p>

<p>Backstage was probably the technology I was least familiar with before starting the Golden Kubestronaut journey. But the concepts are not overly complex and with some reading and hands-on practice you can get up to speed relatively quickly. The <a href="https://backstage.io/docs/overview/what-is-backstage">official Backstage documentation</a> is your best friend here and the KodeKloud CBA course provides a great structured introduction to the platform.</p>

<h3 id="otca--opentelemetry-certified-associate">OTCA — OpenTelemetry Certified Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/opentelemetry-certified-associate-otca/">OTCA</a> is a multiple choice exam covering OpenTelemetry — the CNCF observability framework for traces, metrics, and logs. The exam covers OTel architecture, instrumentation, the collector (receivers, processors, exporters), and how to work with all three telemetry signals. Exam may fill challenging due to the a lot of different terminology like spans, metrics types, context propagation, bagage, etc.</p>

<p>OpenTelemetry is increasingly becoming the standard for observability in cloud native environments so this is a very practical and relevant certification. Focus on understanding the three pillars of observability (traces, metrics, logs), the OTel collector architecture, and how to instrument applications with OTel SDKs. The <a href="https://opentelemetry.io/docs/">official OpenTelemetry documentation</a> is comprehensive and the KodeKloud OTCA course is a solid structured option.</p>

<h3 id="kca--kyverno-certified-associate">KCA — Kyverno Certified Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/kyverno-certified-associate-kca/">KCA</a> is a multiple choice exam focused on Kyverno — a policy engine designed specifically for Kubernetes. The exam covers Kyverno architecture, policy writing (validate, mutate, generate policies), admission webhooks, and policy exceptions.</p>

<p>Kyverno is a fantastic tool for enforcing security and compliance policies in Kubernetes clusters and I’ve been using it in production for a while now, so this one felt very natural to me. If you have experience writing Kyverno policies you will do well here. For those new to Kyverno the <a href="https://kyverno.io/docs/">official Kyverno documentation</a> is excellent.</p>

<h3 id="cnpa--certified-cloud-native-platform-engineering-associate">CNPA — Certified Cloud Native Platform Engineering Associate</h3>

<p><a href="https://training.linuxfoundation.org/certification/certified-cloud-native-platform-engineering-associate-cnpa/">CNPA</a> is a multiple choice easy exam covering platform engineering concepts and practices in a cloud native context. The exam focuses on Internal Developer Platforms (IDPs), the CNCF platform engineering landscape, and developer experience. Platform engineering has become one of the hottest topics in the industry so this is a timely and relevant certification. The <a href="https://tag-app-delivery.cncf.io/whitepapers/platforms/">CNCF Platform Engineering whitepaper</a> is essential reading before taking this exam. Unlike all other multiple choice exams in the Golden Kubestronaut journey, CNPA has ~ 90 questions instead of 60, but the exam time is prolonged accordingly.</p>

<h2 id="tips-for-the-golden-kubestronaut-journey">Tips for the Golden Kubestronaut Journey</h2>

<p>Having gone through the entire journey from zero to Golden Kubestronaut here are some tips that might help you:</p>

<p><strong>1. Don’t rush.</strong> The journey to Golden Kubestronaut is a marathon, not a sprint. Take your time to properly learn each technology rather than just cramming for the exam. The knowledge you gain along the way is far more valuable than the badge itself.</p>

<p><strong>2. Leverage your existing knowledge.</strong> Many of the CNCF associate certifications build on each other. Your Kubernetes knowledge from CKA/CKAD/CKS will help significantly with ICA, CCA, KCA and others. Don’t underestimate how much you already know.</p>

<p><strong>3. Use KodeKloud.</strong> As mentioned above, KodeKloud is the single best resource for this entire journey. Their courses cover virtually every certification you need, and the combination of structured theory and real hands-on labs is exactly the right formula for passing both multiple choice and performance-based exams.</p>

<p><strong>4. Practice with real tools.</strong> Set up a local Kubernetes cluster (kind or minikube) and actually install and use each technology. Hands-on experience is invaluable even for multiple choice exams because it gives you the intuition to eliminate wrong answers confidently. For ICA this is absolutely mandatory — you cannot pass it by reading alone.</p>

<p><strong>5. Watch for CNCF discounts.</strong> CNCF and Linux Foundation regularly offer discounts especially around Black Friday / Cyber Monday (up to 60% off). Buy exam bundles when discounts are available to save significant money — with 15 certifications to collect this adds up quickly.</p>

<h2 id="new-exam-requirement">New Exam Requirement</h2>

<p>Since 2026 March 1st one more exam has been added to the Golden Kubestronaut requirements — the <a href="https://training.linuxfoundation.org/certification/certified-cloud-native-platform-engineer-cnpe/">Certified Cloud Native Platform Engineer (CNPE)</a> which is an advanced hands-on exam focused on platform engineering concepts and practices. Here you need to demonstrate your ability to use a variety of CNCF tools tools like Kubernetes, Argo CD, Backstage, Kyverno, OpenTelemetry, Crossplane, etc to build and operate a cloud native platform. I have not taken this one yet but as far as I heard it is quite interesting, but at the same time very challenging.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Achieving Golden Kubestronaut has been  challenging and rewarding certification journey. It required learning and demonstrating proficiency across the broadest possible slice of the CNCF ecosystem — from core Kubernetes to service mesh, observability, security policies, GitOps, developer portals and platform engineering.</p>

<p>What I love most about this program is that it pushes you to explore technologies you might not encounter in your day-to-day work. Even if you never work with Backstage or Argo Events professionally, understanding these tools makes you a much more well-rounded cloud native engineer and opens your eyes to solutions for problems you didn’t know could be solved this elegantly.</p>

<p>If you are currently on your Kubestronaut journey and wondering whether to go for Golden Kubestronaut as well — I say absolutely go for it! It is a lot of work, but the CNCF ecosystem is rich and fascinating and there is something genuinely satisfying about seeing how all the pieces fit together into a coherent cloud native platform story.</p>

<p><strong>And remember: once a Golden Kubestronaut, always a Golden Kubestronaut!</strong></p>

<p>If you have any questions about the journey or any of the individual certifications feel free to reach out. I will be happy to help!</p>

<p>Thank you for reading and let’s keep learning!!!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Kubernetes" /><category term="LFCS" /><category term="PCA" /><category term="ICA" /><category term="CCA" /><category term="CAPA" /><category term="CGOA" /><category term="CBA" /><category term="OTCA" /><category term="KCA" /><category term="CNPA" /><category term="Golden Kubestronaut" /><category term="Kubestronaut" /><category term="CNCF" /><category term="Cloud Native" /><category term="Open Source" /><category term="K8S" /><category term="IT" /><category term="Security" /><category term="Certification" /><category term="Linux" /><category term="Prometheus" /><category term="Istio" /><category term="Cilium" /><category term="Argo" /><category term="GitOps" /><category term="Backstage" /><category term="OpenTelemetry" /><category term="Kyverno" /><category term="Platform Engineering" /><summary type="html"><![CDATA[My Golden Kubestronaut Journey]]></summary></entry><entry><title type="html">How to Host Trivy DB in Azure Container Registry</title><link href="http://sysadminas.eu/Host-Trivy-DB-in-ACR/" rel="alternate" type="text/html" title="How to Host Trivy DB in Azure Container Registry" /><published>2024-11-25T00:00:00+00:00</published><updated>2024-11-25T00:00:00+00:00</updated><id>http://sysadminas.eu/Host-Trivy-DB-in-ACR</id><content type="html" xml:base="http://sysadminas.eu/Host-Trivy-DB-in-ACR/"><![CDATA[<p><img align="" width="200" height="200" src="../assets/images/post27/2.png" /></p>

<p>Those of you who are using <a href="https://github.com/aquasecurity/trivy">Trivy</a> in your CI/CD pipelines to scan container images for vulnerabilities, you might have noticed that already for some time <code class="language-plaintext highlighter-rouge">trivy image</code> command may fail with the following error:</p>

<pre><code class="language-log">2024-10-29T09:07:18.631+0200	FATAL	init error: DB error: failed to download vulnerability DB: database download error: OCI repository error: 1 error occurred:
	* GET https://ghcr.io/v2/aquasecurity/trivy-db/manifests/2: TOOMANYREQUESTS: retry-after: 825.911µs, allowed: 44000/minute
</code></pre>

<p>This issue is caused by the rate limiting of the GitHub Container Registry (GHCR) and for the last few months, it started to appear very frequently. For more details you can check the <a href="https://github.com/aquasecurity/trivy/discussions/7668">GitHub Issue</a>. My solution for this problem is to host the Trivy DB in a private container registry in my case in Azure Container Registry (ACR) which actually an OCI registry. The DB can be updated on a schedule or on-demand using a Azure DevOps pipelines or just a simple bash script and cron jobs. If you wonder how we can keep the DB in container registry the answer is simple using the <a href="https://opencontainers.org/">OCI Artifacts</a>. Actually OCI registry is a storage for OCI artifacts and this can be anything like images, Helm charts or even videos. So here is the <code class="language-plaintext highlighter-rouge">azure-pipelines.yml</code> file which I use to update the Trivy DB in ACR:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">pr</span><span class="pi">:</span> <span class="s">none</span>
<span class="na">trigger</span><span class="pi">:</span> <span class="s">none</span>

<span class="na">schedules</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0</span><span class="nv"> </span><span class="s">2</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*'</span> <span class="c1"># Run every day at 2:00 UTC</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s">Daily Trivy DB Update and Upload for Dev Azure Container Registry</span>

<span class="na">parameters</span><span class="pi">:</span>      
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">orasVersion</span>
  <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">1.2.0'</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">jqVersion</span>
  <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">1.7.1'</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryServiceConnection</span>
  <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Azure</span><span class="nv"> </span><span class="s">DevOps</span><span class="nv"> </span><span class="s">Service</span><span class="nv"> </span><span class="s">Connection</span><span class="nv"> </span><span class="s">Name</span><span class="nv"> </span><span class="s">for</span><span class="nv"> </span><span class="s">ACR'</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryName</span>
  <span class="na">default</span><span class="pi">:</span> <span class="s1">'</span><span class="s">youracr.azurecr.io'</span>

<span class="na">stages</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Update_Trivy_DB'</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s">Update Trivy DB</span>
  <span class="na">jobs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">DownloadAndUpdateTrivyDB</span>
      <span class="na">displayName</span><span class="pi">:</span> <span class="s">Download and Update for $ environment</span>
      <span class="na">parameters</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">containerRegistryName</span>
        <span class="na">value</span><span class="pi">:</span> <span class="s1">'</span><span class="s">youracr.azurecr.io'</span>
      <span class="na">steps</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">CmdLine@2</span>
          <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Download</span><span class="nv"> </span><span class="s">and</span><span class="nv"> </span><span class="s">Install</span><span class="nv"> </span><span class="s">ORAS'</span>
          <span class="na">inputs</span><span class="pi">:</span>
            <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
              <span class="s">curl -sLO "https://github.com/oras-project/oras/releases/download/v$/oras_$_linux_amd64.tar.gz"</span>
              <span class="s">mkdir -p oras-install/</span>
              <span class="s">tar -zxf oras_$_*.tar.gz -C oras-install/</span>
              <span class="s">sudo mv oras-install/oras /usr/local/bin/</span>
              <span class="s">rm -rf oras_$_*.tar.gz oras-install/</span>
              <span class="s">oras version</span>
        <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">CmdLine@2</span>
          <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Download</span><span class="nv"> </span><span class="s">and</span><span class="nv"> </span><span class="s">Install</span><span class="nv"> </span><span class="s">jq'</span>
          <span class="na">inputs</span><span class="pi">:</span>
            <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
              <span class="s">curl -sL -o jq "https://github.com/jqlang/jq/releases/download/jq-$/jq-linux-amd64"</span>
              <span class="s">chmod +x jq</span>
              <span class="s">sudo mv jq /usr/local/bin/</span>
              <span class="s">jq --version    </span>
        <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Docker@2</span>
          <span class="na">displayName</span><span class="pi">:</span> <span class="s">Login to Container Registry</span>
          <span class="na">inputs</span><span class="pi">:</span>
            <span class="na">command</span><span class="pi">:</span> <span class="s">login</span>
            <span class="na">containerRegistry</span><span class="pi">:</span> <span class="s">$</span>
        <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
          <span class="na">displayName</span><span class="pi">:</span> <span class="s">Download and Update Trivy databases</span>
          <span class="na">inputs</span><span class="pi">:</span>
            <span class="na">workingDirectory</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(System.DefaultWorkingDirectory)'</span>
            <span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
            <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>  
              <span class="s"># Function to retry a command if it fails</span>
              <span class="s">retry() {</span>
                <span class="s">local n=1</span>
                <span class="s">local max=5</span>
                <span class="s">local delay=5</span>
                <span class="s">while true; do</span>
                  <span class="s">"$@" &amp;&amp; break || {</span>
                    <span class="s">if [[ $n -lt $max ]]; then</span>
                      <span class="s">((n++))</span>
                      <span class="s">echo "Command failed. Attempt $n/$max:"</span>
                      <span class="s">sleep $delay;</span>
                    <span class="s">else</span>
                      <span class="s">echo "The command has failed after $n attempts."</span>
                      <span class="s">return 1</span>
                    <span class="s">fi</span>
                  <span class="s">}</span>
                <span class="s">done</span>
              <span class="s">}</span>
              
              <span class="s"># Pull the trivy-db image from ghcr.io with retry logic</span>
              <span class="s">retry oras pull ghcr.io/aquasecurity/trivy-db:2</span>

              <span class="s"># Push the trivy-db image to registry</span>
              <span class="s">oras push --export-manifest manifest.json $/trivy/trivy-db:2 db.tar.gz</span>
              
              <span class="s"># Update the mediaType of the manifest </span>
              <span class="s">jq '.layers[0].mediaType="application/vnd.aquasec.trivy.db.layer.v1.tar+gzip"' manifest.json &gt; trivy_db_manifest.json</span>
              
              <span class="s"># Push the updated manifest to registry</span>
              <span class="s">oras manifest push $/trivy/trivy-db:2 trivy_db_manifest.json</span>

              <span class="s"># Clean up</span>
              <span class="s">rm -f db.tar.gz manifest.json trivy_db_manifest.json</span>

              <span class="s"># Pull the trivy-java-db image from ghcr.io with retry logic</span>
              <span class="s">retry oras pull ghcr.io/aquasecurity/trivy-java-db:1</span>

              <span class="s"># Push the trivy-java-db image to registry and export the manifest</span>
              <span class="s">oras push --export-manifest manifest.json $/trivy/trivy-java-db:1 javadb.tar.gz</span>
              
              <span class="s"># Update the mediaType of the manifest</span>
              <span class="s">jq '.layers[0].mediaType="application/vnd.aquasec.trivy.db.layer.v1.tar+gzip"' manifest.json &gt; trivy_javadb_manifest.json</span>
              
              <span class="s"># Push the updated manifest to registry</span>
              <span class="s">oras manifest push $/trivy/trivy-java-db:1 trivy_javadb_manifest.json</span>

              <span class="s"># Clean up</span>
              <span class="s">rm -f javadb.tar.gz manifest.json trivy_javadb_manifest.json</span>

        <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Bash@3</span>
          <span class="na">displayName</span><span class="pi">:</span> <span class="s">Logout of Container Registry</span>
          <span class="na">inputs</span><span class="pi">:</span>
            <span class="na">targetType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inline'</span>
            <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
              <span class="s">docker logout $</span>
</code></pre></div></div>

<p>In simple words pipeline does the following:</p>

<ol>
  <li>Downloads the ORAS and jq binaries which are needed for the script</li>
  <li>Logs in to the Azure Container Registry</li>
  <li>Pulls the Trivy DB images from GHCR</li>
  <li>Pushes the images to ACR and exports the manifest</li>
  <li>Updates the mediaType of the manifest</li>
  <li>Pushes the updated manifest to ACR</li>
  <li>Repeats the same procedure twice once for the regular Trivy DB and once for the Java Trivy DB</li>
  <li>Logs out of the Azure Container Registry</li>
</ol>

<p>If you will use the above pipeline make sure to replace the parameters with your values. The pipeline is scheduled to run every day at 2:00 UTC. Make sure to use correct Azure DevOps Service Connection of type Docker Registry for the ACR also associated Azure entraID service principal should have the correct PUSH permissions on the ACR.</p>

<p>If you need a more simple bash script version of the above pipeline you can use the following script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="c">#!/bin/bash</span>

<span class="c"># Make sure oras and jq are installed on the client</span>

<span class="c"># Login to Azure Container Registry</span>
az acr login <span class="nt">--name</span> youracr.azurecr.io

<span class="c"># Pull the trivy-db image from ghcr.io</span>
oras pull mirror.gcr.io/aquasecurity/trivy-db:2
<span class="c"># Push the trivy-db image to registry</span>
oras push <span class="nt">--export-manifest</span> manifest.json youracr.azurecr.io/trivy/trivy-db:2 db.tar.gz
<span class="c"># Update the mediaType of the manifest </span>
jq <span class="s1">'.layers[0].mediaType="application/vnd.aquasec.trivy.db.layer.v1.tar+gzip"'</span> manifest.json <span class="o">&gt;</span> trivy_db_manifest.json
<span class="c"># Push the updated manifest to registry</span>
oras manifest push youracr.azurecr.io/trivy/trivy-db:2 trivy_db_manifest.json
<span class="c"># Clean up</span>
<span class="nb">rm</span> <span class="nt">-f</span> db.tar.gz manifest.json trivy_db_manifest.json

<span class="c"># Pull the trivy-java-db image from ghcr.io</span>
oras pull ghcr.io/aquasecurity/trivy-java-db:1
<span class="c"># Push the trivy-java-db image to registry and export the manifest</span>
oras push <span class="nt">--export-manifest</span> manifest.json youracr.azurecr.io/trivy/trivy-java-db:1 db.tar.gz
<span class="c"># Update the mediaType of the manifest</span>
jq <span class="s1">'.layers[0].mediaType="application/vnd.aquasec.trivy.db.layer.v1.tar+gzip"'</span> manifest.json <span class="o">&gt;</span> trivy_java_db_manifest.json
<span class="c"># Push the updated manifest to registry</span>
oras manifest push youracr.azurecr.io/trivy/trivy-java-db:1 trivy_java_db_manifest.json
<span class="c"># Clean up</span>
<span class="nb">rm</span> <span class="nt">-f</span> db.tar.gz manifest.json trivy_java_db_manifest.json
</code></pre></div></div>

<p>Once you have the Trivy DB in your ACR you can use your regular <code class="language-plaintext highlighter-rouge">trivy image</code> command but with additional <code class="language-plaintext highlighter-rouge">--db-repository=youracr.azurecr.io/trivy/trivy-db:2</code> and  <code class="language-plaintext highlighter-rouge">--java-db-repository=youracr.azurecr.io/trivy/trivy-java-db:1</code></p>

<p>Important to note that in <a href="https://github.com/aquasecurity/trivy/pull/7679">one of the latest PR’s</a> this issue is fixed and mirror.gcr.io is now used as a default DB repository, but your will still benefit from hosting the DB in your private registry as you will have more control over the DB updates and you will not be affected by any potential rate limiting. DB download process should be faster as less concurrent downloads will be made compared to the public registry. And of course keeping and using data in/from private registry is more secure.</p>

<p>Also worth mentioning that the above pipeline can be easily converted to GitHub Actions or any other CI/CD tool you are using. Same approach can be used to host the Trivy DB in any other OCI registry like Docker Hub, Quay, AWS ECR etc.</p>

<p>I hope this post was useful for you and you will find it helpful. If you have any questions or suggestions feel free to contact me and I will be happy to discuss it with you.</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Helm" /><category term="Kubernetes" /><category term="Azure" /><category term="CI/CD" /><category term="DevOps" /><category term="Azure DevOps" /><category term="ACR" /><category term="Trivy" /><category term="Security" /><category term="DB" /><category term="Container" /><category term="GitHub" /><category term="Pipeline" /><category term="Docker" /><category term="Image" /><category term="Registry" /><category term="Bash" /><category term="Script" /><category term="Security" /><category term="Scan" /><category term="Vulnerability" /><category term="CVE" /><category term="Database" /><category term="OCI" /><category term="ORAS" /><category term="OCI Artifacts" /><category term="jq" /><category term="OCI Registry" /><summary type="html"><![CDATA[Helm Release Viewer]]></summary></entry><entry><title type="html">My Kubestronaut Journey</title><link href="http://sysadminas.eu/Kubestronaut/" rel="alternate" type="text/html" title="My Kubestronaut Journey" /><published>2024-09-22T00:00:00+00:00</published><updated>2024-09-22T00:00:00+00:00</updated><id>http://sysadminas.eu/Kubestronaut</id><content type="html" xml:base="http://sysadminas.eu/Kubestronaut/"><![CDATA[<p><img align="right" width="400" height="250" src="../assets/images/post26/1.png" /></p>

<p>Hello everyone!</p>

<p>Few days ago I have finished my Kubernetes certification journey and after renewing my CKA, CKAD, CKS certification and additionally passing KCNA and KCSA exams I can officially and proudly call myself a <a href="https://www.cncf.io/training/kubestronaut">Kubestronaut</a>.</p>

<p>What is a Kubestronaut? Kubestronaut is relatively new program announced by CNCF at Kubecon 2024 in Paris. It is a program for Kubernetes professionals who have passed all Kubernetes certifications and have proven their knowledge and skills in Kubernetes ecosystem. Every one who has passed all 5 Kubernetes certifications (CKA, CKAD, CKS, KCNA, KCSA) will be recognized as a Kubestronaut. Worth to mention that Kubestronaut is not only a title, but in addition to that every Kubestronaut will get a special badge and an exclusive Kubestronaut jacket. Also Kubestronauts will get discount for all CNCF events and certifications.</p>

<p>If you ask is it hard to become a Kubestronaut I would say that it is not easy, but it is definitely worth it not only because of the title, but also because of the knowledge and experience you will gain during the learning process and this is probably the most important thing. I hold some k8s certifications like CKA, CKAD, CKS from 2020/21 and I worked with k8s even before that, but still I can say that I have learned some new things and improved my skills during the preparation for re-certification and new exams. Kubernetes is a fast evolving technology and it is important to keep up with the latest updates and new features.</p>

<p>Now let’s talk about exams a little bit.</p>

<p><a href="https://training.linuxfoundation.org/certification/certified-kubernetes-administrator-cka/">CKA or Certified Kubernetes Administrator</a> - this one is the most popular Kubernetes certification and it’s my favorite one. In my opinion it is a must have certification for every Kubernetes administrator/cluster operator. It is a hands-on exam where you need to solve multiple tasks in a real Kubernetes clusters. You maybe asked to create and configure different types of resources, troubleshoot issues with cluster, perform kubernetes version updates in other words all you can expect in a real world scenario. Also some task may require a knowledge of linux administration i.e. checking logs, editing files enabling/disabling services, viewing processes, networking etc. The exam is 2 hours long and you may need to solve 15-20 tasks. Keep in mind that each task may have some so called sub-tasks so you need to act fast. From my experience I can say that this exam is not easy, but not hardest one. If you have experience with Kubernetes and have linux administration skills with few weeks of honest preparation you can pass it. Make sure to take a practice exercises and mock exams before the real exam they really well shows how good you are prepared. Then you register for the exam you will get a free retake as well as a 2 free practice exams for <a href="https://killer.sh">killer.sh</a> platform. For training I can recommend Kodekloud’s CKA course + Mock exam labs. To be honest for re-certification I have used only killer.sh exam simulator and it was enough for me to pass the exam with 90% score.</p>

<blockquote>
  <p>killer.sh provides a real exam experience and it’s tasks in many cases are even harder than the real exam tasks. They have practice exams for CKA, CKAD and CKS certifications and with which exam you will register you will get 2 free practice exams for that certification. Each practice exam is valid for 36 hours and you also have possibility to reset the lab if you want to start from scratch. I highly recommend killer.sh platform for training and preparation for CKA, CKAD and CKS certifications.</p>
</blockquote>

<p><a href="https://training.linuxfoundation.org/certification/certified-kubernetes-application-developer-ckad/">CKAD or Certified Kubernetes Application Developer</a> - this exam is more oriented for developers who deploy, maintain and troubleshoot applications in Kubernetes. Here you can expect tasks like creating and configuring different types of resources, running and configuring application, login, debugging app issues etc. Main difference from CKA is that you can’t expect tasks related to cluster administration like troubleshooting, upgrading clusters or configuring kubelet. Same as CKA this exam is hands-on and you need to solve multiple tasks in a real Kubernetes clusters. The exam is also 2 hours long and you may need to solve 15-20 tasks. From my experience I can say that this exam is easier than CKA in terms of tasks complexity and difficulty, but it is may require more typing (i.e. creating and configuring yaml manifest for k8s resources) and due to that you may need to act fast what could consequently lead to more mistakes. My general advice would be to do not start your Kubernetes certification journey with CKAD, but start with CKA and then go for CKAD in this case with your CKA knowledge you should be able to pass CKAD without any special preparation (just additionally take a look on things like cronjobs, jobs, init containers, liveness and readiness probes etc.). Same as for CKA for re-certification I have used only killer.sh platform and it was enough for me to pass the exam with 89% score. As I mentioned any CKA training course with small addons should be enough for CKAD as well, but you can also take a look on Kodekloud’s CKAD course + Mock exam labs.</p>

<p><a href="https://training.linuxfoundation.org/certification/certified-kubernetes-security-specialist/">CKS or Certified Kubernetes Security Specialist</a> - this exam is around Kubernetes security and it’s covers various security aspects of Kubernetes clusters. Here you can expect tasks like securing cluster components i.e. api-server, etcd or kubelet, configuring audit logging, network policies, pod security standards, apparmor profiles, runtime classes etc. Searching for vulnerabilities, analyzing runtime security or performing static analysis of Dockerfiles or Kubernetes manifests and applying best practices also could be in exam. The exam is 2 hours long and you may need to solve 15-20 tasks. From my experience I can say that this exam is the hardest one from all Kubernetes certifications. It requires a deep knowledge of Kubernetes security and a lot of practice. I have used killer.sh platform for training and was able to solve practice exams with 100% score in less than 2 hours, but to be honest the real exam was more challenging for me and I failed it with 60% (required to pass is 67%)on my first attempt during re-certification (Initially in 2021 I passed exam on my first attempt). Worth to mention that during preparation for CKS re-certification I not only used killer.sh platform, but also watched complete <a href="https://www.youtube.com/watch?v=d9xfB5qaOfg&amp;t=30533s">CKS course from killer.sh on Youtube</a>. I can say that I failed the exam not because of lack of knowledge, but because of lack of time. Some of the tasks are quite complex and may require some time to solve them and double check what you did it right and related components are working as expected after your changes. Once I failed the exam I have used my free retake and scheduled retry right after 3 days and this time I was able to pass it with 69% score. I can’t say what I’m satisfied with this score as I’m confident that I know the topic well and can perform better. I’m just need bit more time to solve and double check all tasks. So my advice for CKS would be to have as much practice as possible and to be fast in solving tasks and if you are really don’t know how to solve some task just mark it for later review and go to the next one. I also think that more complex tasks have more weight in terms of score so make sure to not skip and solve them at least partially as I think that even partially solved task will give you some points.</p>

<p>CKA, CKAD and CKS exams are proctored exams and you need to take them online with a proctor watching you via webcam. You need to have a good internet connection, a quiet private room and a computer with a webcam. You can’t use any additional monitors, mobile phones, books or notes during the exam. During these exams you are allowed to use <a href="https://kubernetes.io/docs">kubernetes.io/docs</a> + some other additional resources depending on the exam. Personally I never had any technical issues with the exam environment or proctoring, just make sure that your PC and environment meets all the requirements. I also recommend to use at least 27 inch monitor as it will be much more comfortable compared to 14-15 inch laptop screen. You exam score will be available after 24 hours after the exam. And if you will not pass you will get a top 3 domains where you need to improve your knowledge.</p>

<p>My recommended learning resources for CKA, CKAD and CKS certifications:</p>

<ul>
  <li><a href="https://killer.sh">killer.sh</a> - practice exams for CKA, CKAD and CKS certifications. Highly recommended!</li>
  <li><a href="https://kodekloud.com/courses?title=CKA">Kodekloud</a> - CKA,CKAD course and Mock exams. Highly recommended!</li>
  <li><a href="https://www.youtube.com/watch?v=d9xfB5qaOfg&amp;t=30533s">CKS course from killer.sh on Youtube</a> - Is a must watch for CKS exam.</li>
  <li><a href="https://killercoda.com/killer-shell-cka">Killercoda</a> - Practice tasks for CKA exam.</li>
  <li><a href="https://github.com/ViktorUJ/cks/tree/master/tasks/cka/mock">CKA Mock exams</a></li>
  <li><a href="https://github.com/ViktorUJ/cks/tree/master/tasks/ckad/mock">CKAD Mock exams</a></li>
  <li><a href="https://github.com/ViktorUJ/cks/tree/master/tasks/cks/mock">CKS Mock exams</a></li>
</ul>

<p>Also keep in mind that Kubernetes is a fast evolving technology and exam domains may change from time to time. Make sure to check the official exam page for the latest updates and exam domains. For example soon there will be a new version of CKA and CKS exams with new domains and tasks. In CKA exam we will have some tasks related to Helm and in CKS exam we will have Cilium related tasks.</p>

<p><a href="https://training.linuxfoundation.org/certification/kubernetes-and-cloud-native-associate/">KCNA or Kubernetes and Cloud Native Associate</a> - this exam is an entry level certification for Kubernetes and Cloud Native technologies. It is a multiple choice exam and you need to answer 60 questions in 90 minutes. The exam covers various topics from Kubernetes basics + plus some additional questions on other CNCF projects like Prometheus. From my experience I can say that this exam is not hard and if you already have passed exams like CKA or CKAD you should be able to pass it without any special preparation. If you want to be super confident you can take a look on <a href="https://learn.kodekloud.com/user/courses?search=KCNA">KCNA course from kodekloud</a> and also take a Mock exam provided by same course. Personally I didn’t take any special preparation and only tried to take the Mock exam which I passed with no issues during first attempt. I passed the real exam with 89% score and it took me less than 30 minutes to answer all questions.</p>

<p><a href="https://training.linuxfoundation.org/certification/kubernetes-and-cloud-native-security-associate/">KCSA or Kubernetes and Cloud Native Security Associate</a> - this exam is also considered as an entry level certification for Kubernetes and Cloud Native technologies, but it is focused on security aspects. It is a multiple choice exam and you need to answer 60 questions in 90 minutes. The exam covers various topics from Kubernetes security basics + plus some general cybersecurity questions for example be familiar with STRIDE model or 4C’s. Comparing to KCNA this exam is a bit harder and requires some readings, but in general you can pass it if you have CKS certification even without any special preparation. Unlike KCNA at current moment there is no official training courses for KCSA, but I can recommend to take a look on this <a href="https://medium.com/@wattsdave/kubernetes-cloud-native-security-associate-kcsa-study-notes-and-exam-prep-f4c8f84d1c4f">blog post</a> where you can find some useful notes for KCSA exam. I passed the real exam with 91% I was not expecting to get such a high score as I was not sure about some questions, but I’m was very happy to finish my Kubestronaut journey on such a good note.</p>

<p>During KCNA and KCSA exams you are not allowed to use any additional resources like it is allowed for CKA, CKAD and CKS exams. For KCNA and KCSA in my case score was emailed to me immediately after the exam.</p>

<p>CNCF exams are not cheap and it hurts especially if you need to take multiple exams and you are not sponsored by your employer. But good thing is that CNCF provides a free retake for all exams and with some exams (like CKA, CKAD, CKS) you also get a free practice tests on killer.sh platform. Very often there are some discounts available especially on days like cyber Monday there usually you can get a 60% discount for all exams. Also worth to mention that CNCF certification are valid for 2 years and after that you need to re-certify. Keep in mind that when you buy an exam you will have 1 year to take it including free retake.</p>

<p>In conclusion I would like to say that Kubernetes is a great technology and it is worth to learn it as it opens a lot of opportunities in IT career. Kubernetes is constantly changing so learning and passing Kubernetes certifications is a great way to stay up to date with. If you are interested in Kubernetes certification and Cloud Native technologies I would recommend to start with CKA and CKAD certifications and then go for CKS, KCNA and KCSA. It is not easy, but it is definitely worth it. And remember like in many other live aspect it not about the destination, but more about the journey so it’s not only about certifications, but also about the knowledge and experience you will gain during the learning process.</p>

<p>If you have any questions or need some help with Kubernetes certifications feel free to contact me. I will be happy to help you of course if it will not violate the CNCF NDA.</p>

<p>Thank you for reading and good luck and let’s do some Kubernetes!!!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Kubernetes" /><category term="CKA" /><category term="CKAD" /><category term="CKS" /><category term="KCNA" /><category term="KCSA" /><category term="Kubestronaut" /><category term="CNCF" /><category term="Cloud Native" /><category term="Open Source" /><category term="K8S" /><category term="IT" /><category term="Security" /><category term="Certification" /><category term="Linux" /><summary type="html"><![CDATA[My Kubestronaut Journey]]></summary></entry><entry><title type="html">Helm Release Viewer</title><link href="http://sysadminas.eu/Helm-Release-Viewer/" rel="alternate" type="text/html" title="Helm Release Viewer" /><published>2024-07-07T00:00:00+00:00</published><updated>2024-07-07T00:00:00+00:00</updated><id>http://sysadminas.eu/Helm-Release-Viewer</id><content type="html" xml:base="http://sysadminas.eu/Helm-Release-Viewer/"><![CDATA[<p><img align="right" width="250" height="250" src="../assets/images/post25/hrv.png" /></p>

<p>Hello everyone!</p>

<p>It’s has been a while since my last post, but now I’m back on track and hopefully will be able to post more frequently. Today I want to share with you a tool that I have been working on for the past few weeks. It’s called Helm Release Viewer. Helm Release Viewer is a web-based UI  that allows you to view the status of your Helm releases in a Kubernetes cluster. It provides a simple and intuitive interface that allows you to see the status of all your releases at a glance. You can filter the releases by namespace, release name, or status, and you can also view information about each release like release revision history, values and manifest files. Helm release viewer is built using Python, Flask, Jinja2 and Gunicorn it can be easily packaged as a Docker image or you can run it locally. Packaging it as a Docker image and having a Helm chart allows you to deploy it in your Kubernetes cluster in a matter of minutes. Helm Release Viewer is open source and available on GitHub at <a href="https://github.com/andriktr/helm-release-viewer">Helm Release Viewer</a> repository.</p>

<p>Now let’s quickly deploy Helm Release Viewer in our Kubernetes cluster. The easiest way to deploy a Helm Release Viewer is to use the Helm chart that I have already created. Raw chart templates are available in the Helm Release Viewer repository and it also automatically being packaged and published to github pages of the repository. So to install Helm Release Viewer in your Kubernetes cluster you can use the following commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Add Helm Release Viewer Helm repository to your Helm</span>
helm repo add helm-release-viewer https://sysadminas.eu/helm-release-viewer
<span class="c"># Update Helm repositories</span>
helm repo update
<span class="c"># List and Test Helm Release Viewer Helm chart</span>
helm template helm-release-viewer/helm-release-viewer
helm upgrade <span class="nt">--install</span> helm-release-viewer helm-release-viewer/helm-release-viewer <span class="nt">--namespace</span> helm-release-viewer <span class="nt">--create-namespace</span> <span class="nt">--dry-run</span>
<span class="c"># Install Helm Release Viewer Helm chart</span>
helm upgrade <span class="nt">--install</span> helm-release-viewer helm-release-viewer/helm-release-viewer <span class="nt">--namespace</span> helm-release-viewer <span class="nt">--create-namespace</span> 
</code></pre></div></div>

<p>Please keep in mind that you can also customize the Helm Release Viewer deployment by providing your own values file. You can find possible configuration options in the <a href="https://github.com/andriktr/helm-release-viewer?tab=readme-ov-file#configure-helm-release-viewer-chart">Helm Release Viewer helm chart configuration section</a>. You can pass your custom values by providing a values file with the <code class="language-plaintext highlighter-rouge">--values</code> flag. For example:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>helm upgrade <span class="nt">--install</span> helm-release-viewer helm-release-viewer/helm-release-viewer <span class="nt">--namespace</span> helm-release-viewer <span class="nt">--create-namespace</span> <span class="nt">--values</span> my-values.yaml
</code></pre></div></div>

<p>or you can also pass the values directly in the command line using the <code class="language-plaintext highlighter-rouge">--set</code> flag. For example:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>helm upgrade <span class="nt">--install</span> helm-release-viewer helm-release-viewer/helm-release-viewer <span class="nt">--namespace</span> helm-release-viewer <span class="nt">--create-namespace</span> <span class="nt">--set</span> deploymwnt.image.tag<span class="o">=</span>latest
</code></pre></div></div>

<p>Successful installation of Helm Release Viewer Helm chart will output message similar to the following:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thank you for installing Helm Release Viewer!
UI normally can be accessed:
 * Locally by running: 
   kubectl port-forward --namespace default svc/helm-release-viewer 8080:80
   and then opening 
   http://localhost:8080 in your web browser
 * Via the internet using the following ingress resource:
   - Hostname: https://helm-release-viewer.mydomain.com/
</code></pre></div></div>

<p>⚠️ <strong>Important:</strong> Usage of ingress is optional however it is highly recommended if you are planning to use Helm Release Viewer in a production environment and you will need more than one replica of the Helm Release Viewer pod. Ingress allows most easiest way to configure sticky sessions which in current version of Helm Release Viewer is required to make sure that filtering and sorting works correctly in multi-replica environment.</p>

<p>⚠️ <strong>Important:</strong> Also by default helm chart is configured to create <code class="language-plaintext highlighter-rouge">ClusterRole</code> and <code class="language-plaintext highlighter-rouge">ClusterRoleBinding</code> which gives Helm Release Viewer service account almost full access to the cluster. However you can easily switch to read only access by changing appropriate values in the <code class="language-plaintext highlighter-rouge">values.yaml</code> file. Also instead of use predefined read only or full access cluster roles you can specify your own cluster role permissions in the <code class="language-plaintext highlighter-rouge">values.yaml</code> file. All these option can be defined in the <code class="language-plaintext highlighter-rouge">values.yaml</code> rbac section.</p>

<p>⚠️ <strong>Important:</strong> Helm Release Viewer selects and analyzes all namespaces in the cluster, but you can also limit it to the some specific namespaces by passing the label selector to the environment variable <code class="language-plaintext highlighter-rouge">SELECTORS</code> in the deployment. For example, to limit the namespaces to only those that have the label <code class="language-plaintext highlighter-rouge">app: myapp</code> you add to <code class="language-plaintext highlighter-rouge">deployment.additionalEnvs</code> new env <code class="language-plaintext highlighter-rouge">SELECTORS</code> and set the value to <code class="language-plaintext highlighter-rouge">app=myapp</code>. You can also pass multiple selectors separated by a comma. For example, to limit the namespaces to only those that have the label <code class="language-plaintext highlighter-rouge">app: myapp</code> and <code class="language-plaintext highlighter-rouge">env: production</code> you set <code class="language-plaintext highlighter-rouge">deployment.additionalEnv</code> <code class="language-plaintext highlighter-rouge">SELECTORS</code> to <code class="language-plaintext highlighter-rouge">app=myapp,env=production</code>.</p>

<p>For complete details on how to configure Helm Release Viewer Helm chart please refer to the <a href="https://github.com/andriktr/helm-release-viewer?tab=readme-ov-file#configure-helm-release-viewer-chart">Helm Release Viewer Helm chart configuration section</a>.</p>

<p>As deployment notes suggest depending on your configuration you can access Helm Release Viewer UI either by port-forwarding the service to your local machine or by using the ingress resource. Of course if your service is exposed via NodePort or LoadBalancer you can also access it using the external IP address of the service.</p>

<p>In my case I deployed Helm Release Viewer in my local kind cluster and I will access it by port-forwarding. In my case service is configured to listen on port 80 which is default value in your case it might be different. To access Helm Release Viewer UI by port-forwarding run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl port-forward <span class="nt">--namespace</span> default svc/helm-release-viewer 8080:80
</code></pre></div></div>

<p>and then open <a href="http://localhost:8080">http://localhost:8080</a> in your web browser Helm Release Viewer UI welcomes you with the following screen:</p>

<p><img align="center" width="" height="" src="../assets/images/post25/1.png" /></p>

<p>As you can see in my environment I have only one release and it’s helm-release-viewer itself. If needed you can filter/search data by namespace, release name or status also sorting by various columns available. Additionally it is possible to view the release revision history, values and manifest files by clicking on appropriate column in the table:</p>

<p>Revision History:
<img align="center" width="" height="" src="../assets/images/post25/2.png" /></p>

<p>Manifest:
<img align="center" width="" height="" src="../assets/images/post25/3.png" /></p>

<p>Values (As I did not provide any values file to the Helm Release Viewer deployment it uses default values which are defined in the <code class="language-plaintext highlighter-rouge">values.yaml</code> file of the Helm chart. As consequence <code class="language-plaintext highlighter-rouge">helm get values</code> command will not return any values so this is why you see empty values in the screenshot above however if you provide your own values file you will see the values that you have provided):</p>

<p><img align="center" width="" height="" src="../assets/images/post25/4.png" /></p>

<p>You also can run application locally in this case your current kubectl context will be used to fetch the data from the cluster. You can pass applications parameters by setting appropriate environment variables.</p>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Description</th>
      <th>Default</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SELECTORS</td>
      <td>A comma separated list of labels (key=value) to select releases only from specific namespaces</td>
      <td>None</td>
    </tr>
    <tr>
      <td>ENVIRONMENT</td>
      <td>The environment in which the application is running. Environment will be displayed on home page</td>
      <td>””</td>
    </tr>
    <tr>
      <td>CACHE_TTL</td>
      <td>The time in seconds to keep the helm releases in cache</td>
      <td>300</td>
    </tr>
    <tr>
      <td>CACHE_SIZE</td>
      <td>The maximum number of entries to keep in cache</td>
      <td>50000</td>
    </tr>
    <tr>
      <td>APP_PORT</td>
      <td>The port on which the application will run</td>
      <td>8080</td>
    </tr>
    <tr>
      <td>APP_WARMUP_INTERVAL</td>
      <td>The time in seconds to automatically hit the home page to warm up the cache</td>
      <td>300</td>
    </tr>
    <tr>
      <td>LOG_LEVEL</td>
      <td>The log level for the application</td>
      <td>info</td>
    </tr>
    <tr>
      <td>IN_CLUSTER</td>
      <td>If set to true, the application will use the in-cluster configuration to interact with the Kubernetes cluster</td>
      <td>true</td>
    </tr>
  </tbody>
</table>

<p>To run the application locally you need to perform the following steps:</p>

<ol>
  <li>Clone the repository</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/andriktr/helm-release-viewer.git
</code></pre></div></div>

<ol>
  <li>Change to the <code class="language-plaintext highlighter-rouge">app</code> directory</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>helm-release-viewer/app
</code></pre></div></div>

<ol>
  <li>Install the required dependencies by running <code class="language-plaintext highlighter-rouge">pip install -r requirements.txt</code></li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
</code></pre></div></div>

<ol>
  <li>Set the <code class="language-plaintext highlighter-rouge">IN_CLUSTER</code> environment variable to <code class="language-plaintext highlighter-rouge">false</code> and if needed set other environment variables</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">IN_CLUSTER</span><span class="o">=</span><span class="nb">false</span>
</code></pre></div></div>

<ol>
  <li>Run the application</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python helm_release_viewer.py
</code></pre></div></div>

<ol>
  <li>Access the application by opening <a href="http://localhost:8080">http://localhost:8080</a> in your web browser</li>
</ol>

<p>To make the application faster and more responsive I have implemented a simple caching mechanism that caches helm releases for specified time. You can configure the cache time and cache size by passing CACHE_TTL and CACHE_SIZE environment variables to the deployment. As my testing show this significantly improves the performance of the application in large clusters with hundreds of namespaces and releases. Additionally to the caching data from namespace is being fetched in parallel which also improves the performance of the application.</p>

<p>Additionally a warm-up mechanism is implemented which fetches all the data from the cluster on the application start-up and does it regularly in the background. This allows to have the data ready when the user opens the application. It also useful in large clusters where fetching all the data can take some time.</p>

<p>So this is the first version of Helm Release Viewer and I have some ideas on how to improve it. I’m planning to add more features like uninstalling releases or rolling back them to desired revision. If you have any ideas or suggestions on how to improve Helm Release Viewer please let me know by creating an issue in the <a href="https://github.com/andriktr/helm-release-viewer/issues">Helm Release Viewer GitHub repository</a>. Also if you would like to contribute to the project you are more than welcome to do so. I’m open to any suggestions and contributions.</p>

<p>I really hope that you will find Helm Release Viewer useful especially if you prefer UI over CLI. I’m looking forward to hearing your feedback and suggestions.</p>

<p>Thank you for reading and stay tuned for more posts!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Helm" /><category term="Kubernetes" /><category term="DevOps" /><category term="Tools" /><category term="Open Source" /><category term="Release" /><category term="Viewer" /><category term="Helm Release Viewer" /><category term="Python" /><category term="Docker" /><category term="Flask" /><category term="Jinja2" /><category term="Gunicorn" /><category term="CSS" /><category term="HTML" /><category term="JavaScript" /><category term="GitHub" /><category term="Docker" /><category term="Image" /><category term="Github" /><category term="Pages" /><category term="Helm Chart" /><category term="WebApp" /><summary type="html"><![CDATA[Helm Release Viewer]]></summary></entry><entry><title type="html">Write and Deploy your first PYTHON app to K8S</title><link href="http://sysadminas.eu/Your-First-Python-app-on-k8s/" rel="alternate" type="text/html" title="Write and Deploy your first PYTHON app to K8S" /><published>2024-01-04T00:00:00+00:00</published><updated>2024-01-04T00:00:00+00:00</updated><id>http://sysadminas.eu/Your-First-Python-app-on-k8s</id><content type="html" xml:base="http://sysadminas.eu/Your-First-Python-app-on-k8s/"><![CDATA[<p><img align="right" width="400" height="400" src="../assets/images/post24/logo.png" /></p>

<p>Hi All,
In one of my previous posts I wrote about how to write and deploy your first Golang app to K8S. In this post we will write a Python app and deploy it to Kubernetes cluster. Even if you not a developer (I’m also not) you for 100% have heard about Python. Python is probably one of the most popular programming languages in the world. It is used in many different areas, from web development to data science. Python is a very powerful language, and it is very easy to learn especially when Chat GPT comes to the rescue. Knowing Python for you as a DevOps engineer, K8S operator or SysAdmin is a huge advantage which can make your life easier and your work more efficient. In this post we will write a simple beginner oriented Python application which will go through the list of the website addresses and check a expire date of the SSL certificates. If the certificate is about to expire in less than X days, the application will send an email to the defined responsible person. The application will be deployed to Kubernetes cluster and will be run as a Cronjob.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>In order to develop and test python application you need to have Python installed on your machine. If you are on macOS you can install Python with Homebrew:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Python with Homebrew</span>
brew <span class="nb">install </span>python
<span class="c"># Confirm Python version</span>
python <span class="nt">--version</span>
</code></pre></div></div>

<p>For all other platforms please follow the official <a href="https://www.python.org/downloads/">installation guide</a></p>

<h2 id="python-application-code">Python application code</h2>

<p>When you have Python installed on your machine you can start writing your first Python application. I’m positioning myself as a Python beginner, so code might not be the super optimized and there might be some areas for improvement, but in general it does the job it’s easy to understand and results are satisfying. So here is the code (let’s name app <code class="language-plaintext highlighter-rouge">cert-checker.py</code>):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">datetime</span>
<span class="kn">import</span> <span class="nn">ssl</span>
<span class="kn">import</span> <span class="nn">socket</span>
<span class="kn">import</span> <span class="nn">logging</span>
<span class="kn">import</span> <span class="nn">sys</span>
<span class="kn">import</span> <span class="nn">time</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">subprocess</span>
<span class="kn">import</span> <span class="nn">yaml</span>
<span class="kn">import</span> <span class="nn">smtplib</span>
<span class="kn">import</span> <span class="nn">warnings</span>

<span class="kn">from</span> <span class="nn">email.mime.text</span> <span class="kn">import</span> <span class="n">MIMEText</span>
<span class="kn">from</span> <span class="nn">email.mime.multipart</span> <span class="kn">import</span> <span class="n">MIMEMultipart</span>
<span class="kn">from</span> <span class="nn">email.mime.application</span> <span class="kn">import</span> <span class="n">MIMEApplication</span>

<span class="n">logging</span><span class="p">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="p">.</span><span class="n">INFO</span> <span class="p">,</span> <span class="n">stream</span><span class="o">=</span><span class="n">sys</span><span class="p">.</span><span class="n">stdout</span><span class="p">,</span> <span class="nb">format</span><span class="o">=</span><span class="s">'%(asctime)s %(levelname)s %(message)s'</span><span class="p">,</span> <span class="n">datefmt</span><span class="o">=</span><span class="s">'%d/%m/%Y %I:%M:%S %p'</span><span class="p">)</span>
<span class="c1"># Suppress the DeprecationWarning
</span><span class="n">warnings</span><span class="p">.</span><span class="n">filterwarnings</span><span class="p">(</span><span class="s">"ignore"</span><span class="p">,</span> <span class="n">category</span><span class="o">=</span><span class="nb">DeprecationWarning</span><span class="p">)</span>

<span class="c1"># Comment out the following lines if you want to run the app locally and uncomment the lines below them if you want to run the app in a container
</span><span class="n">email_alerts_enabled</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"EMAIL_ALERTS_ENABLED"</span><span class="p">]</span>
<span class="n">from_email</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"FROM_EMAIL"</span><span class="p">]</span>
<span class="n">smtp_server</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"SMTP_SERVER"</span><span class="p">]</span>
<span class="n">config_file_path</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"CONFIG_FILE_PATH"</span><span class="p">]</span>

<span class="c1"># Uncomment the following lines if you want to run the app locally and comment out the lines above them if you want to run the app in a container
# email_alerts_enabled = "true"
# from_email = "from_email"
# smtp_server = "smtp_server"
# config_file_path = "config_file_path"
</span>

<span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Starting sites cert checker"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">email_alert</span><span class="p">(</span><span class="n">to_email</span><span class="p">,</span> <span class="n">from_email</span><span class="p">,</span> <span class="n">smtp_server</span><span class="p">,</span> <span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">site_status</span><span class="p">,</span> <span class="n">expiration_date</span><span class="p">):</span>
    <span class="n">msg</span> <span class="o">=</span> <span class="n">MIMEMultipart</span><span class="p">()</span>
    <span class="n">msg</span><span class="p">[</span><span class="s">'To'</span><span class="p">]</span> <span class="o">=</span> <span class="n">to_email</span>
    <span class="n">msg</span><span class="p">[</span><span class="s">'From'</span><span class="p">]</span> <span class="o">=</span> <span class="n">from_email</span>
    <span class="n">msg</span><span class="p">[</span><span class="s">'Subject'</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"CERTIFICATE EXPIRATION ALERT For </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s">"</span>
    <span class="k">if</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"near_expiration"</span><span class="p">:</span>
          <span class="n">body_text_html_part_1</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"&lt;p&gt;Hello,&lt;br&gt;The certificate for &lt;span style=</span><span class="se">\"</span><span class="s">color:yellow</span><span class="se">\"</span><span class="s">&gt;&lt;b&gt;</span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s">&lt;/b&gt;&lt;/span&gt; will expire on &lt;span style=</span><span class="se">\"</span><span class="s">color:yellow</span><span class="se">\"</span><span class="s">&gt;&lt;b&gt;</span><span class="si">{</span><span class="n">expiration_date</span><span class="si">}</span><span class="s">&lt;/b&gt;&lt;/span&gt;.&lt;/p&gt;"</span>
          <span class="n">body_text_html_part_2</span> <span class="o">=</span> <span class="s">"&lt;p&gt;Please take appropriate action to renew the certificate before it expires.&lt;/p&gt;"</span>
          <span class="n">body_html</span> <span class="o">=</span> <span class="n">MIMEText</span><span class="p">(</span><span class="s">"&lt;html&gt;&lt;body&gt;&lt;p&gt;"</span> <span class="o">+</span> <span class="n">body_text_html_part_1</span> <span class="o">+</span> <span class="s">"&lt;/p&gt;&lt;p&gt;"</span> <span class="o">+</span> <span class="n">body_text_html_part_2</span> <span class="o">+</span> <span class="s">"&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;"</span><span class="p">,</span> <span class="s">"html"</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"expired"</span><span class="p">:</span>
          <span class="n">body_text_html_part_1</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"&lt;p&gt;Hello,&lt;br&gt;The certificate for &lt;span style=</span><span class="se">\"</span><span class="s">color:red</span><span class="se">\"</span><span class="s">&gt;&lt;b&gt;</span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s">&lt;/b&gt;&lt;/span&gt; is expired on </span><span class="si">{</span><span class="n">expiration_date</span><span class="si">}</span><span class="s">&lt;/p&gt;"</span>
          <span class="n">body_text_html_part_2</span> <span class="o">=</span> <span class="s">"&lt;p&gt;Please take immediate appropriate action to renew the certificate.&lt;/p&gt;"</span>
          <span class="n">body_html</span> <span class="o">=</span> <span class="n">MIMEText</span><span class="p">(</span><span class="s">"&lt;html&gt;&lt;body&gt;&lt;p&gt;"</span> <span class="o">+</span> <span class="n">body_text_html_part_1</span> <span class="o">+</span> <span class="s">"&lt;/p&gt;&lt;p&gt;"</span> <span class="o">+</span> <span class="n">body_text_html_part_2</span> <span class="o">+</span> <span class="s">"&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;"</span><span class="p">,</span> <span class="s">"html"</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"error"</span> <span class="ow">or</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"unreachable"</span> <span class="ow">or</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"non_ssl_site"</span><span class="p">:</span>
          <span class="n">body_text_html_part_1</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"&lt;p&gt;Hello,&lt;br&gt;There was an error (status code: &lt;span style=</span><span class="se">\"</span><span class="s">color:red</span><span class="se">\"</span><span class="s">&gt;&lt;b&gt;</span><span class="si">{</span><span class="n">site_status</span><span class="si">}</span><span class="s">&lt;/b&gt;&lt;/span&gt;) checking the certificate for &lt;span style=</span><span class="se">\"</span><span class="s">color:red</span><span class="se">\"</span><span class="s">&gt;&lt;b&gt;</span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s">&lt;/b&gt;&lt;/span&gt; please double check if site is reachable and certificate is readable.&lt;/p&gt;"</span>
          <span class="n">body_text_html_part_2</span> <span class="o">=</span> <span class="s">"&lt;p&gt;Please take immediate appropriate action to fix the issue.&lt;/p&gt;"</span>
          <span class="n">body_html</span> <span class="o">=</span> <span class="n">MIMEText</span><span class="p">(</span><span class="s">"&lt;html&gt;&lt;body&gt;&lt;p&gt;"</span> <span class="o">+</span> <span class="n">body_text_html_part_1</span> <span class="o">+</span> <span class="s">"&lt;/p&gt;&lt;p&gt;"</span> <span class="o">+</span> <span class="n">body_text_html_part_2</span> <span class="o">+</span> <span class="s">"&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;"</span><span class="p">,</span> <span class="s">"html"</span><span class="p">)</span>
    <span class="n">msg</span><span class="p">.</span><span class="n">attach</span><span class="p">(</span><span class="n">body_html</span><span class="p">)</span>
    <span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"----- Sending alert to </span><span class="si">{</span><span class="n">to_email</span><span class="si">}</span><span class="s">-----"</span><span class="p">)</span>
    <span class="n">server</span> <span class="o">=</span> <span class="n">smtplib</span><span class="p">.</span><span class="n">SMTP</span><span class="p">(</span><span class="n">smtp_server</span><span class="p">)</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">server</span><span class="p">.</span><span class="n">sendmail</span><span class="p">(</span><span class="n">from_email</span><span class="p">,</span> <span class="n">to_email</span><span class="p">,</span> <span class="n">msg</span><span class="p">.</span><span class="n">as_string</span><span class="p">())</span>
        <span class="n">server</span><span class="p">.</span><span class="n">quit</span><span class="p">()</span>
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="n">logging</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="sa">f</span><span class="s">"Error sending email: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        <span class="k">raise</span> <span class="nb">Exception</span><span class="p">(</span><span class="sa">f</span><span class="s">"Error sending email"</span><span class="p">)</span> <span class="k">from</span> <span class="n">e</span>

<span class="k">def</span> <span class="nf">check_site_availability</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">):</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="c1"># First, try to establish an SSL connection
</span>        <span class="n">sock</span> <span class="o">=</span> <span class="n">socket</span><span class="p">.</span><span class="n">create_connection</span><span class="p">((</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">),</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
        <span class="n">context</span> <span class="o">=</span> <span class="n">ssl</span><span class="p">.</span><span class="n">SSLContext</span><span class="p">(</span><span class="n">ssl</span><span class="p">.</span><span class="n">PROTOCOL_TLS</span><span class="p">)</span>
        <span class="n">context</span><span class="p">.</span><span class="n">verify_mode</span> <span class="o">=</span> <span class="n">ssl</span><span class="p">.</span><span class="n">CERT_REQUIRED</span>
        <span class="n">context</span><span class="p">.</span><span class="n">check_hostname</span> <span class="o">=</span> <span class="bp">True</span>
        <span class="n">context</span><span class="p">.</span><span class="n">load_default_certs</span><span class="p">()</span>
        <span class="n">conn</span> <span class="o">=</span> <span class="n">context</span><span class="p">.</span><span class="n">wrap_socket</span><span class="p">(</span><span class="n">sock</span><span class="p">,</span> <span class="n">server_hostname</span><span class="o">=</span><span class="n">host</span><span class="p">)</span>
        <span class="n">conn</span><span class="p">.</span><span class="n">close</span><span class="p">()</span>
        <span class="k">return</span> <span class="s">"available"</span>
    <span class="k">except</span> <span class="n">ssl</span><span class="p">.</span><span class="n">SSLError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="c1"># If the SSL connection fails with an SSLError, check the message of the exception
</span>        <span class="k">if</span> <span class="s">"certificate verify failed"</span> <span class="ow">in</span> <span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">):</span>
            <span class="c1"># This means the site is an SSL site but has an untrusted certificate
</span>            <span class="k">return</span> <span class="s">"untrusted_certificate"</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="c1"># This means the site is not an SSL site, try to establish a non-SSL connection
</span>            <span class="k">try</span><span class="p">:</span>
                <span class="n">sock</span> <span class="o">=</span> <span class="n">socket</span><span class="p">.</span><span class="n">create_connection</span><span class="p">((</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">),</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
                <span class="n">sock</span><span class="p">.</span><span class="n">close</span><span class="p">()</span>
                <span class="k">return</span> <span class="s">"non_ssl_site"</span>
            <span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
                <span class="c1"># If the non-SSL connection also fails, the site is unreachable
</span>                <span class="k">return</span> <span class="s">"unreachable"</span>
    <span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
        <span class="c1"># If the SSL connection fails with a different exception, the site is unreachable
</span>        <span class="k">return</span> <span class="s">"unreachable"</span>
            
<span class="k">def</span> <span class="nf">site_cert_checker</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">validity_threshold</span><span class="p">):</span>
     <span class="k">try</span><span class="p">:</span>
          <span class="n">cmd</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"echo | openssl s_client -servername </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s"> -connect </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">port</span><span class="si">}</span><span class="s"> 2&gt;/dev/null | openssl x509 -noout -enddate 2&gt;/dev/null"</span>
          <span class="n">result</span> <span class="o">=</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">check_output</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span> <span class="n">shell</span><span class="o">=</span><span class="bp">True</span><span class="p">).</span><span class="n">decode</span><span class="p">().</span><span class="n">strip</span><span class="p">()</span>
          <span class="n">expiration_date_str</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">"="</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span>
          <span class="n">expiration_date</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="n">datetime</span><span class="p">.</span><span class="n">strptime</span><span class="p">(</span><span class="n">expiration_date_str</span><span class="p">,</span> <span class="s">'%b %d %H:%M:%S %Y %Z'</span><span class="p">)</span>           
          <span class="c1">#Calculate the date 30 days from now
</span>          <span class="n">threshold_date</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="n">datetime</span><span class="p">.</span><span class="n">utcnow</span><span class="p">()</span> <span class="o">+</span> <span class="n">datetime</span><span class="p">.</span><span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="n">validity_threshold</span><span class="p">)</span>
          <span class="n">current_date</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="n">datetime</span><span class="p">.</span><span class="n">utcnow</span><span class="p">()</span>
          <span class="c1"># Check if the certificate will expire in less than 30 days
</span>          <span class="k">if</span> <span class="n">expiration_date</span> <span class="o">&lt;</span> <span class="n">threshold_date</span><span class="p">:</span>
               <span class="n">logging</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="sa">f</span><span class="s">"The certificate for </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s"> will expire on </span><span class="si">{</span><span class="n">expiration_date</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
               <span class="n">site_status</span> <span class="o">=</span> <span class="s">"near_expiration"</span>
          <span class="k">elif</span> <span class="n">expiration_date</span> <span class="o">&lt;</span> <span class="n">current_date</span><span class="p">:</span>
               <span class="n">logging</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="sa">f</span><span class="s">"The certificate for </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s"> is expired!"</span><span class="p">)</span>
               <span class="n">site_status</span> <span class="o">=</span> <span class="s">"expired"</span>                
          <span class="k">else</span><span class="p">:</span>
               <span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"The certificate for </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s"> is still valid until </span><span class="si">{</span><span class="n">expiration_date</span><span class="si">}</span><span class="s"> so it's more than defined </span><span class="si">{</span><span class="n">validity_threshold</span><span class="si">}</span><span class="s"> days threshold"</span><span class="p">)</span>
               <span class="n">site_status</span> <span class="o">=</span> <span class="s">"valid"</span>
     <span class="k">except</span> <span class="n">subprocess</span><span class="p">.</span><span class="n">CalledProcessError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
          <span class="n">logging</span><span class="p">.</span><span class="n">exception</span><span class="p">(</span><span class="sa">f</span><span class="s">"Error checking site </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
          <span class="n">site_status</span> <span class="o">=</span> <span class="s">"error"</span>
     <span class="k">return</span> <span class="n">site_status</span><span class="p">,</span> <span class="n">expiration_date</span>

<span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
     <span class="c1"># In case you want to inspect the container, uncomment the following line this will give you 20 minutes to inspect the container then 
</span>     <span class="c1"># main code will start running. It's useful for debugging purposes for example if you want to check if the config file is mounted correctly.
</span>     <span class="c1">#time.sleep(1200)
</span>     
     <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">config_file_path</span><span class="p">,</span> <span class="s">'r'</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">data</span> <span class="o">=</span> <span class="n">yaml</span><span class="p">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
        <span class="n">sites</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'sites'</span><span class="p">,</span> <span class="p">[])</span>
        <span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Total amount of sites to be checked: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">sites</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">site</span> <span class="ow">in</span> <span class="n">sites</span><span class="p">:</span>
            <span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">validity_threshold</span><span class="p">,</span> <span class="n">to_email</span> <span class="o">=</span> <span class="n">site</span><span class="p">[</span><span class="s">'name'</span><span class="p">],</span> <span class="n">site</span><span class="p">[</span><span class="s">'port'</span><span class="p">],</span> <span class="n">site</span><span class="p">[</span><span class="s">'threshold'</span><span class="p">],</span> <span class="n">site</span><span class="p">[</span><span class="s">'email'</span><span class="p">]</span>
            <span class="n">logging</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"Checking site: </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
            <span class="n">site_availability</span> <span class="o">=</span> <span class="n">check_site_availability</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">site_availability</span> <span class="o">!=</span> <span class="s">"unreachable"</span> <span class="ow">and</span> <span class="n">site_availability</span> <span class="o">!=</span> <span class="s">"non_ssl_site"</span><span class="p">:</span>
               <span class="n">site_status</span><span class="p">,</span> <span class="n">expiration_date</span> <span class="o">=</span> <span class="n">site_cert_checker</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">validity_threshold</span><span class="p">)</span>
               <span class="k">if</span> <span class="n">email_alerts_enabled</span> <span class="o">==</span> <span class="s">"true"</span><span class="p">:</span>
                    <span class="k">for</span> <span class="n">email</span> <span class="ow">in</span> <span class="n">to_email</span><span class="p">:</span>
                         <span class="k">if</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"near_expiration"</span><span class="p">:</span>
                              <span class="n">email_alert</span><span class="p">(</span><span class="n">email</span><span class="p">,</span> <span class="n">from_email</span><span class="p">,</span> <span class="n">smtp_server</span><span class="p">,</span> <span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">site_status</span><span class="p">,</span> <span class="n">expiration_date</span><span class="p">)</span>
                         <span class="k">if</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"expired"</span><span class="p">:</span>
                              <span class="n">email_alert</span><span class="p">(</span><span class="n">email</span><span class="p">,</span> <span class="n">from_email</span><span class="p">,</span> <span class="n">smtp_server</span><span class="p">,</span> <span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">site_status</span><span class="p">,</span> <span class="n">expiration_date</span><span class="p">)</span>
                         <span class="k">if</span> <span class="n">site_status</span> <span class="o">==</span> <span class="s">"error"</span><span class="p">:</span>
                              <span class="n">email_alert</span><span class="p">(</span><span class="n">email</span><span class="p">,</span> <span class="n">from_email</span><span class="p">,</span> <span class="n">smtp_server</span><span class="p">,</span> <span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">site_status</span><span class="p">,</span> <span class="s">"N/A"</span><span class="p">)</span>             
            <span class="k">else</span><span class="p">:</span>
                <span class="k">if</span> <span class="n">site_availability</span> <span class="o">==</span> <span class="s">"unreachable"</span><span class="p">:</span>
                    <span class="n">logging</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="sa">f</span><span class="s">"Site </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s"> is unreachable!"</span><span class="p">)</span>
                    <span class="n">site_status</span> <span class="o">=</span> <span class="n">site_availability</span>
                    <span class="k">if</span> <span class="n">email_alerts_enabled</span> <span class="o">==</span> <span class="s">"true"</span><span class="p">:</span>
                         <span class="k">for</span> <span class="n">email</span> <span class="ow">in</span> <span class="n">to_email</span><span class="p">:</span>
                              <span class="n">email_alert</span><span class="p">(</span><span class="n">email</span><span class="p">,</span> <span class="n">from_email</span><span class="p">,</span> <span class="n">smtp_server</span><span class="p">,</span> <span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">site_status</span><span class="p">,</span> <span class="s">"N/A"</span><span class="p">)</span>
                    <span class="k">continue</span>
                <span class="k">if</span> <span class="n">site_availability</span> <span class="o">==</span> <span class="s">"non_ssl_site"</span><span class="p">:</span>
                    <span class="n">logging</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="sa">f</span><span class="s">"Site </span><span class="si">{</span><span class="n">host</span><span class="si">}</span><span class="s"> is not an SSL site!"</span><span class="p">)</span>
                    <span class="n">site_status</span> <span class="o">=</span> <span class="n">site_availability</span>
                    <span class="k">if</span> <span class="n">email_alerts_enabled</span> <span class="o">==</span> <span class="s">"true"</span><span class="p">:</span>
                         <span class="k">for</span> <span class="n">email</span> <span class="ow">in</span> <span class="n">to_email</span><span class="p">:</span>
                              <span class="n">email_alert</span><span class="p">(</span><span class="n">email</span><span class="p">,</span> <span class="n">from_email</span><span class="p">,</span> <span class="n">smtp_server</span><span class="p">,</span> <span class="n">host</span><span class="p">,</span> <span class="n">port</span><span class="p">,</span> <span class="n">site_status</span><span class="p">,</span> <span class="s">"N/A"</span><span class="p">)</span>
                    <span class="k">continue</span>
<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
     <span class="n">main</span><span class="p">()</span>
</code></pre></div></div>

<h2 id="run-the-application-locally">Run the application locally</h2>

<p>As you can see the code is pretty simple and should be easy to understand if you have some basic Python knowledge. Any way let’s shortly describe what the code does:</p>

<ul>
  <li>
    <p>First of all we import all the necessary modules. Then we define some variables which will be used later in the code. Then we define a function which will send an email alert to the responsible person(-s).</p>
  </li>
  <li>
    <p>Then we define a function which will check if the site is available.</p>
  </li>
  <li>
    <p>Next we define a function which will check the certificate expiration date.</p>
  </li>
  <li>
    <p>Then we define a main function which will be the main entry point of our application. In the main function we read the config file which is a <code class="language-plaintext highlighter-rouge">sitse.yaml</code> file containing the list of the sites to be checked. The structure of the <code class="language-plaintext highlighter-rouge">sites.yaml</code> file is the following:</p>
  </li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">sites</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">site1.com</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">443</span>
    <span class="na">threshold</span><span class="pi">:</span> <span class="m">30</span>
    <span class="na">email</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">email1@domain.com</span>
      <span class="pi">-</span> <span class="s">email2@domain.com</span>
</code></pre></div></div>

<p>Then we iterate through the list of the sites and check the certificate expiration date. If the certificate is expire or about to expire in less than 30 days, the application will send an email to the responsible email address(-es) defined in the <code class="language-plaintext highlighter-rouge">sites.yaml</code> file.
Also if the site is unreachable or not an SSL site the application will send appropriate email alert to the responsible person(-s) email address(-es) defined in the <code class="language-plaintext highlighter-rouge">sites.yaml</code> file. Optionally you can enable or disable email alerts by setting the <code class="language-plaintext highlighter-rouge">EMAIL_ALERTS_ENABLED</code> environment variable to <code class="language-plaintext highlighter-rouge">true</code> or <code class="language-plaintext highlighter-rouge">false</code>. If you set it to <code class="language-plaintext highlighter-rouge">false</code> the application will not send any email alerts and will output all the logs to the console only.</p>

<p>We now have to steps completed. We have Python installed on our machine and we have a Python application code ready. Now let’s specify the requirements for our application and create a <code class="language-plaintext highlighter-rouge">requirements.txt</code> file which will contain all the necessary modules which our application needs to run. The <code class="language-plaintext highlighter-rouge">requirements.txt</code> file will look like this:</p>

<pre><code class="language-txt">PyYAML
</code></pre>

<p>In our case we need only one module which is <code class="language-plaintext highlighter-rouge">PyYAML</code> which is a YAML parser and emitter for Python. Also we do not specify the version of the module, so the latest version will be installed. In case you want to specify the version of the module you can do it like this:</p>

<pre><code class="language-txt">PyYAML==5.4.1
</code></pre>

<p>To install the module specified in the <code class="language-plaintext highlighter-rouge">requirements.txt</code> file you can run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
</code></pre></div></div>

<p>Now we have all the necessary prerequisites to run our application locally. Application is meant to be run in a container, but with simple comment/uncomment of the lines in the code you can run it locally. To run the application locally adjusting the following lines in the code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Comment out the following lines if you want to run the app locally and uncomment the lines below them if you want to run the app in a container
</span><span class="n">email_alerts_enabled</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"EMAIL_ALERTS_ENABLED"</span><span class="p">]</span>
<span class="n">from_email</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"FROM_EMAIL"</span><span class="p">]</span>
<span class="n">smtp_server</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"SMTP_SERVER"</span><span class="p">]</span>
<span class="n">config_file_path</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"CONFIG_FILE_PATH"</span><span class="p">]</span>

<span class="c1"># Uncomment the following lines if you want to run the app locally and comment out the lines above them if you want to run the app in a container
# email_alerts_enabled = "true"
# from_email = "from_email"
# smtp_server = "smtp_server"
# config_file_path = "config_file_path"
</span></code></pre></div></div>

<p>Then create a <code class="language-plaintext highlighter-rouge">sites.yaml</code> and put it in the CONFIG_FILE_PATH directory. The <code class="language-plaintext highlighter-rouge">sites.yaml</code> file should be formatted as follows:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">sites</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">sysadminas.eu</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">443</span>
    <span class="na">threshold</span><span class="pi">:</span> <span class="m">30</span>
    <span class="na">email</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">andriktr@gmail.com</span>
</code></pre></div></div>

<p>Then run the application with the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python cert-checker.py
</code></pre></div></div>

<p>If everything is configured correctly application will process and output the logs to the console and send email alert if conditions are met. The output in the console will look like this (here we are checking <a href="sysadminas.eu">sysadminas.eu</a> site certificate):</p>

<p><img align="center" width="" height="" src="../assets/images/post24/1.png" /></p>

<h2 id="deploy-the-application-to-kubernetes-cluster">Deploy the application to Kubernetes cluster</h2>

<p>OK we now tested application locally and it works as expected. Our aim is to deploy the application to Kubernetes cluster. So let’s do it. First let’s wrap our application in a Docker container. To do that we need to create a <code class="language-plaintext highlighter-rouge">Dockerfile</code> which will look like this:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> python:3.12-slim</span>

<span class="k">RUN </span>apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> curl openssl

<span class="k">RUN </span><span class="nb">mkdir</span> /app

<span class="k">WORKDIR</span><span class="s"> /app</span>

<span class="k">COPY</span><span class="s"> requirements.txt .</span>

<span class="k">RUN </span>python <span class="nt">-m</span> pip <span class="nb">install</span> <span class="nt">--upgrade</span> pip
<span class="k">RUN </span>pip <span class="nb">install</span> <span class="nt">--no-cache-dir</span> <span class="nt">-r</span> requirements.txt <span class="o">&amp;&amp;</span> <span class="nb">rm </span>requirements.txt

<span class="k">USER</span><span class="s"> 1000</span>

<span class="k">COPY</span><span class="s"> cert-checker.py .</span>

<span class="k">CMD</span><span class="s"> ["python", "cert-checker.py"]</span>
</code></pre></div></div>

<p>In the <code class="language-plaintext highlighter-rouge">Dockerfile</code> we are using the official Python 3.12 slim image as a base image. Then we install <code class="language-plaintext highlighter-rouge">curl</code> (having curl might be useful for debugging purposes) and <code class="language-plaintext highlighter-rouge">openssl</code> (openssl is required for <code class="language-plaintext highlighter-rouge">site_cert_checker</code> function where it used to check certificate) packages. Then we create a directory for our application and set it as a working directory. Next we copy the <code class="language-plaintext highlighter-rouge">requirements.txt</code> file to the working directory and install all the necessary modules. Also we set USER to 1000 as per best security practices. Then we copy the <code class="language-plaintext highlighter-rouge">cert-checker.py</code> file to the working directory and set the entry point of the container to run the <code class="language-plaintext highlighter-rouge">cert-checker.py</code> file. Now we can build our Docker image with the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Build Docker image</span>
docker build <span class="nt">-t</span> andriktr/cert-checker:0.1.0 <span class="nt">--no-cache</span> <span class="nt">--pull</span> <span class="nt">--platform</span> linux/amd64 <span class="nb">.</span>
<span class="c"># Push Docker image to Docker Hub</span>
docker push andriktr/cert-checker:0.1.0
</code></pre></div></div>

<p>Best way to deploy our application to Kubernetes cluster is to use Helm chart. I have already written a Helm chart for this application which you can find <a href="https://github.com/andriktr/cert-checker/tree/main/cert-checker">here</a>. Easiest way would be to clone the <a href="https://github.com/andriktr/cert-checker.git">cert-checker repository</a>, adjust the values in the <code class="language-plaintext highlighter-rouge">values.yaml</code> file and run helm command for deployment. So let’s do it.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Clone the repository</span>
git clone https://github.com/andriktr/cert-checker.git
</code></pre></div></div>

<p>Now let’s adjust the values in the <a href="https://github.com/andriktr/cert-checker/blob/main/cert-checker/values.yaml">values.yaml</a> file. You can find the description of the values in the <a href="https://github.com/andriktr/cert-checker#configure-cert-checker-helm-chart">README.md</a> file.</p>

<p>Once ready we can test and deploy the Helm chart with the following commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test cert-checker Helm chart</span>
helm upgrade <span class="nt">--install</span> cert-checker cert-checker <span class="nt">--namespace</span> cert-checker <span class="nt">--create-namespace</span> <span class="nt">--dry-run</span> <span class="nt">--debug</span>

<span class="c"># Deploy cert-checker Helm chart</span>
helm upgrade <span class="nt">--install</span> cert-checker cert-checker <span class="nt">--namespace</span> cert-checker <span class="nt">--create-namespace</span>
</code></pre></div></div>

<p>Once you run the above commands you should see a cronjob created in the <code class="language-plaintext highlighter-rouge">cert-checker</code> namespace which will spin up a pod according the schedule specified in the <code class="language-plaintext highlighter-rouge">values.yaml</code> file.</p>

<p><img align="center" width="" height="" src="../assets/images/post24/2.png" /></p>

<p>Repository <a href="https://github.com/andriktr/cert-checker.git">cert-checker</a> contains all the necessary files and instructions to test and deploy the application either locally or to Kubernetes cluster. Feel free to use it and adjust it according to your needs.</p>

<p>I hope you will find this post useful and it will help you in your day to day work.</p>

<p>See you in the next post 🤜 🤛, bye!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Microsoft" /><category term="AzureCLI" /><category term="Script" /><category term="YAML" /><category term="AKS" /><category term="K8S" /><category term="Kubernetes" /><category term="Deployments" /><category term="Bash" /><category term="Demo" /><category term="Linux" /><category term="Security" /><category term="Ingress" /><category term="Python" /><category term="Development" /><category term="SSL" /><category term="TLS" /><category term="Certificates" /><category term="Programming" /><category term="Cronjob" /><category term="Helm" /><category term="Helm Chart" /><category term="Docker" /><category term="Application" /><category term="AI" /><category term="ChatGPT" /><summary type="html"><![CDATA[Write and Deploy your first PYTHON app to K8S]]></summary></entry><entry><title type="html">Consul OIDC Authentication with Azure AD</title><link href="http://sysadminas.eu/Consul-OIDC-Auth-with-AzureAD/" rel="alternate" type="text/html" title="Consul OIDC Authentication with Azure AD" /><published>2023-04-01T00:00:00+00:00</published><updated>2023-04-01T00:00:00+00:00</updated><id>http://sysadminas.eu/Consul-OIDC-Auth-with-AzureAD</id><content type="html" xml:base="http://sysadminas.eu/Consul-OIDC-Auth-with-AzureAD/"><![CDATA[<p><img align="right" width="550" height="240" src="../assets/images/post23/Logo.png" /></p>

<p>Hi All,</p>

<p>This post will be a references to my recently created github repository <a href="https://github.com/andriktr/consul-oidc-azure-ad">Consul-OIDC-Azure-AD</a> which contains a Terraform configuration which will help you to configure OIDC authentication with Azure AD in your Consul cluster.</p>

<p>If you like me using Consul as a Service Mesh and your cloud provider is Azure it will be a really good idea to use Azure AD as your OIDC provider. In this case you will be able to assign Consul roles to your Azure AD users and groups and your users will be able to authenticate to Consul using their Azure AD credentials. This will simplify your life as you will no longer need to issue separate Consul tokens for your users.</p>

<p>So I encourage you to check out <a href="https://github.com/andriktr/consul-oidc-azure-ad">Consul-OIDC-Azure-AD</a> repository and try it out. There is a detailed <a href="https://github.com/andriktr/consul-oidc-azure-ad#readme">README</a> which will guide you through the process of deploying the configuration.</p>

<p>If you have any questions or suggestions please feel free to contact me.</p>

<p>See you soon in the next post 🤜 🤛, bye!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Microsoft" /><category term="AzureCLI" /><category term="Script" /><category term="Helm" /><category term="YAML" /><category term="AKS" /><category term="K8S" /><category term="Kubernetes" /><category term="Deployments" /><category term="Bash" /><category term="Demo" /><category term="Linux" /><category term="Security" /><category term="Terraform" /><category term="Consul" /><category term="ActiveDirectory" /><category term="IaC" /><category term="ServiceMesh" /><category term="OIDC" /><category term="AzureAD" /><category term="Authentication" /><category term="RBAC" /><category term="Authorization" /><summary type="html"><![CDATA[Consul OIDC Authentication with Azure AD]]></summary></entry><entry><title type="html">Tools for successful AKS journey</title><link href="http://sysadminas.eu/Tools-For-Successful-AKS-Journey/" rel="alternate" type="text/html" title="Tools for successful AKS journey" /><published>2023-01-02T00:00:00+00:00</published><updated>2023-01-02T00:00:00+00:00</updated><id>http://sysadminas.eu/Tools-For-Successful-AKS-Journey</id><content type="html" xml:base="http://sysadminas.eu/Tools-For-Successful-AKS-Journey/"><![CDATA[<p><img align="right" width="350" height="250" src="../assets/images/post22/post-logo.png" /></p>

<p>Hi All,</p>

<p>Kubernetes has become the de facto standard for container orchestration. It is a powerful tool that can help you manage your containerized applications. However, it is not a silver bullet. It is a complex system that requires a lot of attention and care. In this post I try to overview some tools and general best practices which IMO are must have for successful Kubernetes (AKS) journey.</p>

<h2 id="infrastructure-as-code">Infrastructure as Code</h2>

<p>In current DevOps era infrastructure as code is a must have for any serious IT project. It allows you to automate the deployment of your infrastructure and to manage it in a consistent and repeatable way. It also allows you to version control your infrastructure and to track changes. If we talk about AKS specifically there are a bunch of different tools which you can use for IaC accomplishment. You can use Azure CLI, Terraform, ARM templates, Ansible, Azure Bicep, Powershell etc. Each of these tools has its own pros and cons, but personally I prefer to use Hashicorp’s <a href="https://www.terraform.io/">Terraform</a> which is one of the most popular and easy to use IaC languages in todays DevOps market. Terraform covers all the major cloud providers and much more. You can find more information about Terraform and AKS <a href="https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/kubernetes_cluster">here</a>.
Having your infrastructure as code is a great decision, but keep in mind that cloud providers constantly evolving and changing their API’s. So the code which you wrote X days ago may not work today. So you need to have a constant automated way to test your code on a regular basis. In case of AKS infrastructure from my experience I can recommend to have some kind of experimental environment which should be deployed using exact same code as was used for dev/prod environments. Then you should have an automated process for example a CI/CD pipeline which will regularly redeploy this experimental environment from scratch and will run some smoke tests. It’s quite easy to achieve with Terraform and Azure DevOps. By having such setup you will be sure that your IaC code is working and you will be able to redeploy/update other business critical environments. Also such approach may help you to test new features and new versions of your infrastructure before rolling them out to more important environments.</p>

<h2 id="cluster-maintenance-and-management">Cluster maintenance and management</h2>

<p>When we have our Kubernetes cluster up and running we need to have right tools to manage it. There are allot of tools which can help you with that and here are some of them which I use and recommend.</p>

<h3 id="kubectl">Kubectl</h3>

<p><a href="https://kubernetes.io/docs/reference/kubectl/overview/">kubectl</a> - is a number 1 command-line tool for controlling Kubernetes clusters. It allows you to run commands against Kubernetes clusters. You can use it to deploy applications, inspect and manage cluster resources, and view logs. It is a must have tool for any Kubernetes cluster.</p>

<h3 id="lens">Lens</h3>

<p><a href="https://k8slens.dev/">Lens</a> - It is a desktop application that allows you to manage your Kubernetes clusters. It is a great tool for developers and DevOps engineers. It has a lot of useful features like cluster management, monitoring, debugging, and more. It is available for Windows, Mac, and Linux.</p>

<h3 id="kube-node_shell">Kube-node_shell</h3>

<p><a href="kubectl-node_shell">kube-node_shell</a> - is a simple tool that allows you to run commands on your Kubernetes nodes. It is a great tool for troubleshooting and debugging. It is a must have tool for any Kubernetes cluster.</p>

<h3 id="k9s">K9S</h3>

<p><a href="https://github.com/derailed/k9s">k9s</a> - provides a terminal UI to interact with your Kubernetes clusters. The aim of this project is to make it easier to navigate, observe and manage your applications in the wild. K9s continually watches Kubernetes for changes and offers subsequent commands to interact with your observed resources.</p>

<h3 id="kured">Kured</h3>

<p><a href="https://github.com/kubereboot/kured">kured/kubereboot</a> - is a Kubernetes DaemonSet that performs safe automatic node reboots when required. It is a great tool for cluster maintenance and management. In terms of AKS it is a must have tool because node updates are managed automatically by Azure however you as a cluster administrator is responsible for the nodes reboot and kured/kubereboot can help you with that.</p>

<h2 id="networking">Networking</h2>

<p>Another very important and probably most dificult part of Kubernetes ecosystem is networking. It is crucial to have a secure, reliable, easy configurable connections from and to applications running in your cluster.</p>

<h3 id="ingress-controller">Ingress Controller</h3>

<p>If you plan to expose your applications outside of your cluster you may need to have an Ingress controller. There are a lot of Ingress controllers like Nginx, Traefik, HAProxy, Kong etc. Personally I use open source version of ingress for official k8s repo <a href="https://github.com/kubernetes/ingress-nginx">ingress-nginx</a>. I choose it because it is highly supported by community easy to use and configure. As alternative you may consider to use <a href="https://learn.microsoft.com/en-us/azure/application-gateway/tutorial-ingress-controller-add-on-new">Azure Application Gateway Ingress Controller</a> which is more native for AKS. Also there other NGINX ingress version officially distributed by NGINX/F5 <a href="https://www.nginx.com/products/nginx/kubernetes-ingress-controller/">F5 NGINX Ingress Controller for Kubernetes</a> whey offer free and paid versions.
<a href="https://blog.palark.com/wp-content/uploads/2019/10/kubernetes-ingress-comparison.png">Here</a> you can find some comparison between top ingress controllers.</p>

<h3 id="service-mesh">Service Mesh</h3>

<p>A service mesh is a dedicated infrastructure layer that adds features to a network between services. It allows to control traffic and gain insights throughout the system. Observability, traffic shifting (for canary releasing), resiliency features (such as circuit breaking and retry/timeout) and automatic mutual TLS can be configured once and enforced in a decentralized fashion. In contrast to libraries, which are used for similar functionality, a service mesh does not require code changes. Instead, it adds a layer of additional containers that implement the features reliably and agnostic to technology or programming language. There are a lot of service meshes like Istio, Linkerd, Consul, Open Service Mesh etc. Currently I’m using <a href="https://www.consul.io/">Consul</a> which is quite is very powerful service mesh with a lot of different features. Main benefit of Consul is that it can be used not only for K8S service but with any other service outside the cluster. However be ready to spend some time in Consul documentation in order to understand how everything works. In terms of AKS the only one officially supported service mesh is <a href="https://openservicemesh.io/">Open Service Mesh(OSM)</a>. OSM is driven by Microsoft and can be enabled as add-on. If you want to get more info about service meshes including detailed comparison of various solutions I encourage you to check the <a href="https://servicemesh.es/">following source</a>.</p>

<h2 id="high-availability">High Availability</h2>

<h3 id="autoscaling">Autoscaling</h3>

<p>In all production environments high availability is a critical requirement. First things which is needed to achieve high availability in AKS and make sure that your pods will be scheduled is to have autoscaling enabled and properly configured. You can find more information about AKS autoscaling <a href="https://learn.microsoft.com/en-us/azure/aks/cluster-autoscaler">here</a>. Autoscaling is a great tool for high availability it will make sure that you have enough compute resource to run your applications. Down scaling will ensure that you will not pay for unused resources.</p>

<h3 id="horizontal-pod-autoscaler">Horizontal Pod Autoscaler</h3>

<p>Horizontal Pod Autoscaler automatically scales the number of pods in a replication controller, deployment, replica set or stateful set based on observed CPU utilization (or, with custom metrics support, on some other application-provided metrics). You can find more information about HPA <a href="https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/">here</a>. HPA is a great tool for high availability it will make sure that you have enough compute resource to run your applications.</p>

<h3 id="descheduler">Descheduler</h3>

<p>Scheduling in Kubernetes is the process of binding pending pods to nodes, and is performed by a component of Kubernetes called kube-scheduler. The scheduler’s decisions, whether or where a pod can or can not be scheduled, are guided by its configurable policy which comprises of set of rules, called predicates and priorities. The scheduler’s decisions are influenced by its view of a Kubernetes cluster at that point of time when a new pod appears for scheduling. As Kubernetes clusters are very dynamic and their state changes over time, there may be desire to move already running pods to some other nodes for various reasons:</p>

<p>Some nodes are under or over utilized.
The original scheduling decision does not hold true any more, as taints or labels are added to or removed from nodes, pod/node affinity requirements are not satisfied any more.
Some nodes failed and their pods moved to other nodes.
New nodes are added to clusters.
Consequently, there might be several pods scheduled on less desired nodes in a cluster. <a href="https://github.com/kubernetes-sigs/descheduler">Descheduler</a>, based on its policy, finds pods that can be moved and evicts them. Please note, in current implementation, descheduler does not schedule replacement of evicted pods but relies on the default scheduler for that.</p>

<h3 id="keda">KEDA</h3>

<p><a href="https://github.com/kedacore/keda">KEDA</a> allows for fine-grained autoscaling (including to/from zero) for event driven Kubernetes workloads. KEDA serves as a Kubernetes Metrics Server and allows users to define autoscaling rules using a dedicated Kubernetes custom resource definition. For <a href="https://learn.microsoft.com/en-us/azure/aks/keda-about">AKS KEDA</a> can be enabled as add-on.</p>

<h2 id="monitoring-and-logging">Monitoring and Logging</h2>

<h3 id="kube-capacity">Kube-capacity</h3>

<p><a href="https://github.com/robscott/kube-capacity">kube-capacity</a> - kube-capacity is a simple CLI tool (it’s like a kubectl top, but more advanced version) that provides an overview of the resource requests, limits, and utilization in your cluster. It is a great tool for monitoring your cluster and to see how much resources you have available and utilized.</p>

<h3 id="kube-prometheus-stack">Kube-prometheus-stack</h3>

<p><a href="https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack">kube-prometheus-stack</a> - kube-prometheus-stack is a collection of Kubernetes manifests, Grafana dashboards, and Prometheus rules combined with documentation and scripts to provide easy to operate end-to-end Kubernetes cluster monitoring with Prometheus using the Prometheus Operator.</p>

<h3 id="filebeat-and-fluentd">Filebeat and Fluentd</h3>

<p><a href="https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-overview.html">filebeat</a> - Filebeat is a lightweight shipper for forwarding and centralizing log data. It is a great tool for collecting the logs from your pods and sending them to the central location such as ElasticSearch. As alternative you can consider to use <a href="https://www.fluentd.org/">Fluentd</a>.</p>

<h3 id="azure-monitor">Azure Monitor</h3>

<p>For AKS monitoring you can combine existing tools with built-in Azure monitoring solutions such as <a href="https://learn.microsoft.com/en-us/azure/aks/monitor-aks">Azure Monitor</a> and <a href="https://docs.microsoft.com/en-us/azure/azure-monitor/insights/container-insights-overview">Azure Monitor for containers</a>. You can also use <a href="https://learn.microsoft.com/en-us/azure/azure-monitor/visualize/workbooks-overview">Azure Workbooks</a> to create custom dashboards and <a href="https://docs.microsoft.com/en-us/azure/azure-monitor/platform/alerts-overview">Azure Alerts</a> to create alerts and be notified when something goes wrong.</p>

<h2 id="cicd">CI/CD</h2>

<h3 id="azure-devops">Azure DevOps</h3>

<p><a href="https://azure.microsoft.com/en-us/products/devops/#overview">Azure DevOps</a> is a great tool for CI/CD. You can use it to build, test and deploy your application image to AKS according best Git practices.</p>

<h3 id="helm">Helm</h3>

<p>There are couple of different template languages for Kubernetes such as <a href="https://helm.sh/">Helm</a>, [Kustomize](https://kustomize.io/, <a href="https://github.com/kapicorp/kapitan">Kapitan</a>. However Helm is the most popular one. Helm helps you manage Kubernetes applications — Helm Charts help you define, install, and upgrade even the most complex Kubernetes application. Helm can be easily integrated in any CI/CD solution   like Azure DevOps Pipelines. Also almost all serious application providers offer Helm charts for their applications.</p>

<h3 id="argocd">ArgoCD</h3>

<p>Both <a href="https://argoproj.github.io/argo-cd/">ArgoCD</a> continuous delivery. Comparing to Azure DevOps Pipelines ArgoCD works in pull mode. It means that ArgoCD will pull the latest version of your application from the repository and deploy it to the cluster. ArgoCD syncs the cluster state with the desired state defined in the Git repository. Also ArgoCD supports not only plain yaml files but Helm charts. Competitor of ArgoCD is <a href="https://fluxcd.io/">Flux</a> it has less UI and it is more CLI oriented.</p>

<h2 id="security">Security</h2>

<h3 id="kyverno">Kyverno</h3>

<p><a href="https://kyverno.io/">Kyverno</a> is a policy engine designed for Kubernetes. Using Kyverno, you can enforce policies that are impossible using native Kubernetes admission controls. Using kyverno you can control your cluster easily and effectively. All pod security best practices can be implemented using Kyverno policies. Even more you can write policies which will generate new resources like quotas, network policies, limitranges, etc. this help automate your cluster management. Comparing tools like <a href="https://github.com/open-policy-agent/gatekeeper">OPA Gatekeeper</a> Kyverno is a winner IMO, because writing policies in kyverno is much easier and do not require familiarity with Rego language also Kyverno allows to generate new resources based on policies.</p>

<h3 id="azure-policy">Azure Policy</h3>

<p>In case if Open Source tools are not allowed by your company when your choice for AKS cluster protection should be <a href="https://learn.microsoft.com/en-us/azure/governance/policy/concepts/policy-for-kubernetes">Azure Policy for Kubernetes</a> which are based on OPA Gatekeeper engine.</p>

<p>In my case I merge Kyverno and Azure Policy together. I use Kyverno for in all modes (enforce, audit, generate) and Azure Policy for audit only.</p>

<h3 id="azure-workload-identity">Azure Workload Identity</h3>

<p>In order to securely access Azure resources such as Azure Key Vault or Storage Accounts from your AKS cluster using <a href="https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview">Azure Workload Identity</a> is a must. It allows you to securely access Azure resources from your AKS cluster pods without using any credentials.</p>

<h3 id="trivy-operator">Trivy-operator</h3>

<p>The <a href="https://github.com/aquasecurity/trivy-operator">Trivy-Operator</a> leverages trivy security tools by incorporating their outputs into Kubernetes CRDs (Custom Resource Definitions) and from there, making security reports accessible through the Kubernetes API. This way users can find and view the risks that relate to different resources in what we call a Kubernetes-native way.
The Trivy operator automatically updates security reports in response to workload and other changes on a Kubernetes cluster, generating the following reports:</p>

<ul>
  <li>Vulnerability Scans: Automated vulnerability scanning for Kubernetes workloads.</li>
  <li>ConfigAudit Scans: Automated configuration audits for Kubernetes resources with predefined rules or custom Open Policy Agent (OPA) policies.</li>
  <li>Exposed Secret Scans: Automated secret scans which find and detail the location of exposed Secrets within your cluster.</li>
  <li>RBAC scans: Role Based Access Control scans provide detailed information on the access rights of the different resources installed.</li>
  <li>K8s core component infra assessment scan Kubernetes infra core components (etcd,apiserver,scheduler,controller-manager and etc) setting and configuration.</li>
  <li>Compliance reports NSA, CISA Kubernetes Hardening Guidance v1.1 cybersecurity technical report is produced.</li>
</ul>

<h3 id="private-container-registry">Private container registry</h3>

<p>If you really aware about your cluster security and container images you should use a private container registries instead of public ones. In terms of Azure and AKS you can use <a href="https://docs.microsoft.com/en-us/azure/container-registry/container-registry-intro">Azure Container Registry</a> which can be easily attached to your AKS cluster. Please note that even if you use private container registry you should still use image scanning tools like <a href="https://github.com/aquasecurity/trivy">trivy</a> and integrate this process into your CI/CD pipeline. This will help you to find and prevent vulnerabilities in your container images before you push them to the registry and deploy to the cluster.</p>

<h3 id="microsoft-defender-for-cloud">Microsoft Defender for Cloud</h3>

<p><a href="https://learn.microsoft.com/en-us/azure/defender-for-cloud/">Microsoft Defender for Cloud</a> also has some features which can help you to secure your cluster and prevent misconfigurations. The following features are available:</p>

<ul>
  <li>Protects Kubernetes clusters running on Azure Kubernetes Service and Kubernetes on-premises/IaaS</li>
  <li>Identifies misconfigurations in Kubernetes clusters</li>
  <li>Vulnerability assessment for images stored in ACR registries</li>
  <li>Vulnerability assessment for images running in Azure Kubernetes Service</li>
  <li>Run-time threat protection for nodes and clusters</li>
  <li>Provides guidelines to help investigate and mitigate identified threats</li>
</ul>

<p>Azure defender for cloud support most of major cloud providers as well as on-premises Kubernetes clusters. You can find more details <a href="https://learn.microsoft.com/en-us/azure/defender-for-cloud/supported-machines-endpoint-solutions-clouds-containers?tabs=azure-aks&amp;WT.mc_id=Portal-Microsoft_Azure_Security#supported-features-by-environment">here</a>.</p>

<h3 id="falco">Falco</h3>

<p><a href="https://falco.org/">Falco</a> is a runtime security tool that helps you to monitor your Kubernetes cluster. Falco is a Cloud Native Computing Foundation (CNCF) project. Falco is a behavioral activity monitor designed to detect anomalous activity in your applications. You can use Falco to monitor run-time security of your Kubernetes applications and internal components. Falco allows you to continuously monitor and detect container, application, and host-level anomalous activity. Falco leverages the rules engine and Lua scripting engine to define and execute rules. Falco is a great tool for detecting threats and vulnerabilities in your cluster.</p>

<h2 id="disaster-recovery">Disaster Recovery</h2>

<p>Having a disaster recovery plan is a critical peace of infrastructure puzzle in any organization. First thing first we already have our infrastructure defined in the code this will help us to easily rebuild everything in case of disaster, misconfigurations, malicious actions, etc. When in terms of AKS or Kubernetes we simply need to redeploy our applications to the fresh cluster as probably most of microservices running in k8s are typically stateless. However I’m more than sure that you will have some stateful apps as well. For such cases we need to have a regular backups which will save us in case of disaster or if for some reason our application data will become inconsistent. For this I can highly recommend <a href="https://kasten.io">Kasten or K10</a>. I strongly believe that K10 currently is Nr.1 backup solution for Kubernetes environments. If your clusters are not large enough (up to 5 nodes) you can use Kasten for free. For larger infrastructures be ready to pay some $$$. Also as alternative you can consider to use <a href="https://velero.io/">Velero</a> which is also a great tool for backup and restore your Kubernetes workloads.</p>

<h2 id="costs">Costs</h2>

<p>Last but not least we need to be able to evaluate our infrastructure costs and split them by different teams, projects, departments, etc. One of the tools which can be helpful is <a href="https://docs.kubecost.com/">Kubecost</a>. This tools helps you monitor and manage cost and capacity in Kubernetes environments and has integration with major cloud providers.</p>

<h2 id="conclusion">Conclusion</h2>

<p>Kubernetes is a great tool for building and managing containerized applications. However it’s not enough to just deploy a cluster and run your applications. You need to add some work in order to have well managed, secure and cost-effective ecosystem.</p>

<p>I hope this post will be helpful for you and would like to thank you for reading it.</p>

<p>See you 🤜 🤛, bye!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Microsoft" /><category term="AzureCLI" /><category term="Script" /><category term="Helm" /><category term="YAML" /><category term="AKS" /><category term="K8S" /><category term="Kubernetes" /><category term="Deployments" /><category term="Bash" /><category term="Demo" /><category term="Docker" /><category term="Linux" /><category term="Pod" /><category term="Trivy" /><category term="Trivy-Operator" /><category term="Aquasec" /><category term="CVE" /><category term="Vulnerability" /><category term="Security" /><category term="Lens" /><category term="Containers" /><category term="Grafana" /><category term="Prometheus" /><category term="Kured" /><category term="Prometheus" /><category term="Descheduler" /><category term="Kubecost" /><category term="Terraform" /><category term="Argo CD" /><category term="Consul" /><category term="Ingress" /><category term="Falco" /><category term="NGINX" /><category term="Istio" /><category term="Linkerd" /><category term="K9S" /><category term="Kube-node_shell" /><category term="IaC" /><category term="ServiceMesh" /><category term="kube-capacity" /><category term="kyverno" /><summary type="html"><![CDATA[Tools for successful AKS journey]]></summary></entry><entry><title type="html">Check your cluster security state with Trivy-operator</title><link href="http://sysadminas.eu/Part-10-AKS/" rel="alternate" type="text/html" title="Check your cluster security state with Trivy-operator" /><published>2022-12-01T00:00:00+00:00</published><updated>2022-12-01T00:00:00+00:00</updated><id>http://sysadminas.eu/Part-10-AKS</id><content type="html" xml:base="http://sysadminas.eu/Part-10-AKS/"><![CDATA[<p><img align="right" width="220" height="220" src="../assets/images/post21/trivy-operator-logo.png" /></p>

<p>Hi All,</p>

<p>In todays post I would like to talk about <a href="https://github.com/aquasecurity/trivy-operator">trivy-operator</a>. Trivy-operator is a Kubernetes operator which can provide:</p>

<ul>
  <li>
    <p>Vulnerability reports for container images running in your cluster</p>
  </li>
  <li>
    <p>Configuration Audit reports for your cluster</p>
  </li>
  <li>
    <p>Exposed secret reports for your cluster</p>
  </li>
  <li>
    <p>RBAC Assessment reports for your cluster</p>
  </li>
  <li>
    <p>Cluster RBAC Assessment reports for your cluster</p>
  </li>
</ul>

<p>It is based on Trivy (I wrote about <code class="language-plaintext highlighter-rouge">trivy</code> in one of my <a href="https://sysadminas.eu/ACR-Manage-and-Secure/#scan-acr-images">previous post</a> already), a simple and comprehensive vulnerability scanner for containers and other artifacts. Trivy is a static analysis tool that detects vulnerabilities of OS packages (Alpine, RHEL, CentOS, etc.) and application dependencies (Bundler, Composer, npm, yarn, etc.) in container images. Both <code class="language-plaintext highlighter-rouge">trivy</code> and <code class="language-plaintext highlighter-rouge">trivy-operator</code> are open source projects and are developed by <a href="https://www.aquasec.com/">Aqua Security</a>.</p>

<p>In terms of Kubernetes <code class="language-plaintext highlighter-rouge">trivy-operator</code> is a deployment which runs in your cluster which watches your namespaces and scans target workloads like pod, replicasets, statefulsets, daemonsets, jobs, cronjobs, secrets, roles, rolebindings, clusterroles, clusterrolebindings.</p>

<p>Trivy-operator deployment process is pretty straightforward. However in terms of AKS if we use Azure Container Registry (ACR) as private registry for images used in our cluster we need to use <code class="language-plaintext highlighter-rouge">Azure Pod Identity</code> in order to make <code class="language-plaintext highlighter-rouge">trivy-operator</code> able to scan these images and access ACR. If you are not familiar with <code class="language-plaintext highlighter-rouge">Azure Pod Identity</code> I would recommend to read my <a href="https://sysadminas.eu/Part-6-AKS/">previous post</a> about it.</p>

<p>So let’s get started and jump into our demo. First thing first let’s define a common variables which we will use in our setup.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Azure Tenant ID</span>
<span class="nb">export </span><span class="nv">AZ_TENANT_ID</span><span class="o">=</span><span class="s2">"&lt;Your Azure Tenant ID goes here&gt;"</span>
<span class="c"># Azure Subscription Name</span>
<span class="nb">export </span><span class="nv">AZ_SUBSCRIPTION_NAME</span><span class="o">=</span><span class="s2">"Visual Studio Premium with MSDN"</span>
<span class="c"># Location</span>
<span class="nb">export </span><span class="nv">AZ_LOCATION</span><span class="o">=</span><span class="s2">"westeurope"</span>
<span class="c"># AKS Cluster Name</span>
<span class="nb">export </span><span class="nv">AKS_RESOURCE_GROUP</span><span class="o">=</span><span class="s2">"sysadminas-aks"</span>
<span class="c"># AKS Cluster Name </span>
<span class="nb">export </span><span class="nv">AKS_CLUSTER_NAME</span><span class="o">=</span><span class="s2">"sysadminas"</span>
<span class="c"># AKS Cluster Node Resource Group Name</span>
<span class="nb">export </span><span class="nv">AKS_NODE_RESOURCE_GROUP</span><span class="o">=</span><span class="s2">"sysadminas-aks-nodes"</span>
<span class="c"># Resource Group for ACR</span>
<span class="nb">export </span><span class="nv">ACR_RESOURCE_GROUP</span><span class="o">=</span><span class="s2">"sysadminas-aks"</span>
<span class="c"># Name of the ACR used in AKS cluster</span>
<span class="nb">export </span><span class="nv">ACR_NAME</span><span class="o">=</span><span class="s2">"sysadminas"</span>
<span class="c"># Name of the Azure managed identity which will be used by trivy-operator</span>
<span class="nb">export </span><span class="nv">AZ_IDENTITY_NAME</span><span class="o">=</span><span class="s2">"sysadminas-trivy-operator-identity"</span>
<span class="c"># Name of K8S namespace where deployment </span>
<span class="nb">export </span><span class="nv">NAMESPACE</span><span class="o">=</span><span class="s2">"trivy-operator"</span>
</code></pre></div></div>

<p>Next ensure that our AKS cluster has <code class="language-plaintext highlighter-rouge">Azure Pod Identity</code> addon enabled. To do that we need to run following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Enable Azure Pod Identity (it safe to run it even if it is already enabled)</span>
az aks update <span class="nt">--enable-pod-identity</span> <span class="nt">--subscription</span> <span class="nv">$AZ_SUBSCRIPTION_NAME</span> <span class="nt">--resource-group</span> <span class="nv">$AKS_RESOURCE_GROUP</span> <span class="nt">--name</span> <span class="nv">$AKS_CLUSTER_NAME</span>
</code></pre></div></div>

<p>Now we need to create a new Azure managed identity which will be used by <code class="language-plaintext highlighter-rouge">trivy-operator</code> to access ACR. Also we will assign identity to the AKS cluster scale set. To do that we need to run following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create Azure managed identity</span>
az identity create <span class="nt">--name</span> <span class="nv">$AZ_IDENTITY_NAME</span> <span class="nt">--subscription</span> <span class="nv">$AZ_SUBSCRIPTION_NAME</span> <span class="nt">--resource-group</span> <span class="nv">$AKS_RESOURCE_GROUP</span>  <span class="nt">--location</span> <span class="nv">$AZ_LOCATION</span>
<span class="c"># Get Azure managed identity resource ID and save it as a variable</span>
<span class="nb">export </span><span class="nv">IDENTITY_ID</span><span class="o">=</span><span class="si">$(</span>az identity show <span class="nt">--name</span> <span class="nv">$AZ_IDENTITY_NAME</span> <span class="nt">--resource-group</span> <span class="nv">$AKS_RESOURCE_GROUP</span> <span class="nt">--subscription</span> <span class="s2">"</span><span class="nv">$AZ_SUBSCRIPTION_NAME</span><span class="s2">"</span> <span class="nt">--query</span> <span class="nb">id</span> <span class="nt">-o</span> tsv<span class="si">)</span>
<span class="c"># Get Azure managed identity client ID and save it as a variable</span>
<span class="nb">export </span><span class="nv">IDENTITY_CLIENT_ID</span><span class="o">=</span><span class="si">$(</span>az identity show <span class="nt">--name</span> <span class="nv">$AZ_IDENTITY_NAME</span> <span class="nt">--resource-group</span> <span class="nv">$AKS_RESOURCE_GROUP</span> <span class="nt">--subscription</span> <span class="s2">"</span><span class="nv">$AZ_SUBSCRIPTION_NAME</span><span class="s2">"</span>  <span class="nt">--query</span> clientId <span class="nt">-o</span> tsv<span class="si">)</span>
<span class="c"># Get AKS cluster scale set name and save it as a variable</span>
<span class="nb">export </span><span class="nv">AKS_SCALE_SET</span><span class="o">=</span><span class="si">$(</span>az vmss list <span class="nt">--resource-group</span> <span class="nv">$AKS_NODE_RESOURCE_GROUP</span> <span class="nt">--subscription</span> <span class="s2">"</span><span class="nv">$AZ_SUBSCRIPTION_NAME</span><span class="s2">"</span> <span class="nt">--query</span> <span class="s2">"[].name"</span> <span class="nt">-o</span> tsv<span class="si">)</span>
<span class="c"># Assign Azure managed identity to the AKS cluster scale set</span>
az vmss identity assign <span class="nt">--resource-group</span> <span class="nv">$AKS_NODE_RESOURCE_GROUP</span> <span class="nt">-n</span> <span class="nv">$AKS_SCALE_SET</span> <span class="nt">--identities</span> <span class="nv">$IDENTITY_ID</span> <span class="nt">--subscription</span> <span class="s2">"</span><span class="nv">$AZ_SUBSCRIPTION_NAME</span><span class="s2">"</span>
<span class="c"># Get ACR resource ID and save it as a variable</span>
<span class="nb">export </span><span class="nv">ACR_RESOURCE_ID</span><span class="o">=</span><span class="si">$(</span>az acr show <span class="nt">--name</span> <span class="nv">$ACR_NAME</span> <span class="nt">--resource-group</span> <span class="nv">$ACR_RESOURCE_GROUP</span> <span class="nt">--subscription</span> <span class="s2">"</span><span class="nv">$AZ_SUBSCRIPTION_NAME</span><span class="s2">"</span> <span class="nt">--query</span> <span class="nb">id</span> <span class="nt">-o</span> tsv<span class="si">)</span>
<span class="c"># Assign ACR pull role to the Azure managed identity</span>
az role assignment create <span class="nt">--role</span> acrpull <span class="nt">--assignee</span> <span class="nv">$IDENTITY_CLIENT_ID</span> <span class="nt">--scope</span> <span class="nv">$ACR_RESOURCE_ID</span> <span class="nt">--subscription</span> <span class="s2">"</span><span class="nv">$AZ_SUBSCRIPTION_NAME</span><span class="s2">"</span>
</code></pre></div></div>

<p>OK, we seems finished with Azure side of things and now we can switch to Kubernetes side. Let’s create a namespace for <code class="language-plaintext highlighter-rouge">trivy-operator</code> as well as azure identity and binding for it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># create namespace for trivy-operator</span>
kubectl create namespace <span class="nv">$NAMESPACE</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># create azure identity for trivy-operator</span>
<span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh"> | kubectl apply -f -
apiVersion: aadpodidentity.k8s.io/v1
kind: AzureIdentity
metadata:
  name: </span><span class="nv">$AZ_IDENTITY_NAME</span><span class="sh">
  namespace: </span><span class="nv">$NAMESPACE</span><span class="sh">
spec:
    clientID: </span><span class="nv">$IDENTITY_CLIENT_ID</span><span class="sh">
    resourceID: </span><span class="nv">$IDENTITY_ID</span><span class="sh">
    type: 0 
</span><span class="no">EOF
</span></code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># create azure identity binding for trivy-operator</span>
<span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh"> | kubectl apply -f -
apiVersion: "aadpodidentity.k8s.io/v1"
kind: AzureIdentityBinding
metadata:
  name: </span><span class="nv">$AZ_IDENTITY_NAME</span><span class="sh">
  namespace: </span><span class="nv">$NAMESPACE</span><span class="sh"> 
spec:
  azureIdentity: </span><span class="nv">$AZ_IDENTITY_NAME</span><span class="sh">  
  selector: </span><span class="nv">$AZ_IDENTITY_NAME</span><span class="sh">
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Now we can configure and deploy <code class="language-plaintext highlighter-rouge">trivy-operator</code>. First let’s add Helm repository which contains <code class="language-plaintext highlighter-rouge">trivy-operator</code> chart and pull it to our local machine:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># add Helm repository</span>
helm repo add aqua https://aquasecurity.github.io/helm-charts/
<span class="c"># update Helm repositories</span>
helm repo update
<span class="c"># pull trivy-operator chart to local machine</span>
helm pull aqua/trivy-operator <span class="nt">--untar</span>
</code></pre></div></div>

<p>Next let’s configure <code class="language-plaintext highlighter-rouge">trivy-operator</code> chart by creating our own <code class="language-plaintext highlighter-rouge">values.yaml</code> file to override some of defaults. We will save our settings into the separate file:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">touch </span>trivy-operator-values.yaml
<span class="nb">cat</span> <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh"> &gt;&gt; trivy-operator-values.yaml
excludeNamespaces: "kube-system"

operator:
  # scanJobsConcurrentLimit the maximum number of scan jobs create by the operator
  scanJobsConcurrentLimit: 2  
  # scanJobsRetryDelay the duration to wait before retrying a failed scan job
  scanJobsRetryDelay: 60s
  # infraAssessmentScannerEnabled the flag to enable infra assessment scanner
  infraAssessmentScannerEnabled: false
  # metricsVulnIdEnabled the flag to enable metrics about cve vulns id
  # be aware of metrics cardinality is significantly increased with this feature enabled.
  metricsVulnIdEnabled: true

# Prometheus ServiceMonitor configuration (enable this if you use Prometheus Operator and want to scrape metrics from trivy-operator)
serviceMonitor:
  # enabled determines whether a serviceMonitor should be deployed
  enabled: true
  # The namespace where Prometheus expects to find service monitors
  namespace: "kube-prometheus-stack"
  interval: "60s"
  # Additional labels for the serviceMonitor
  labels:
    release: kube-prometheus-stack

trivyOperator:
  # Our AAD Pod Identity selector which we set in AzureIdentityBinding
  scanJobPodTemplateLabels: "aadpodidbinding=sysadminas-trivy-operator-identity"

trivy:
  # resources resource limits (During my tests I have noticed that default values may be not enough for trivy to scan images)) 
  resources:
    limits:
      cpu: 2000m
      memory: 2000M
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Now we are ready to deploy <code class="language-plaintext highlighter-rouge">trivy-operator</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run helm template to generate Kubernetes manifests</span>
helm template trivy-operator trivy-operator <span class="nt">--namespace</span> <span class="nv">$NAMESPACE</span> <span class="nt">--values</span> trivy-operator-values.yaml
<span class="c"># Run helm install/upgrade --dry-run --debug to see what will be deployed</span>
helm upgrade <span class="nt">-i</span> <span class="nt">--dry-run</span> <span class="nt">--debug</span> trivy-operator trivy-operator <span class="nt">--namespace</span> <span class="nv">$NAMESPACE</span> <span class="nt">--values</span> trivy-operator-values.yaml
<span class="c"># Run helm install/upgrade to deploy trivy-operator</span>
helm upgrade <span class="nt">-i</span> trivy-operator trivy-operator <span class="nt">--namespace</span> <span class="nv">$NAMESPACE</span> <span class="nt">--values</span> trivy-operator-values.yaml
</code></pre></div></div>

<p>Once we have deployed <code class="language-plaintext highlighter-rouge">trivy-operator</code> it immediately starts scanning all selected workloads in the cluster. Also once you spin up new pod, replicasets, deployment, etc. it will be scanned automatically.
Trivy-operator scan jobs in kubernetes are represented as <code class="language-plaintext highlighter-rouge">jobs.batch</code> objects. Kubernetes <code class="language-plaintext highlighter-rouge">jobs.batch</code> creates a pod and runs it until it completes. Once the job is completed, the pod is deleted.</p>

<p><img align="center" width="" height="" src="../assets/images/post21/Trivy-operator-in-action.png" /></p>

<p>So we have deployed <code class="language-plaintext highlighter-rouge">trivy-operator</code> and it is scanning our cluster. But how can we see the results of the scan? Currently I see three options:</p>

<ol>
  <li>Use built in trivy-operator reports which which in kubernetes are represented as custom resources:</li>
</ol>

<p><img align="center" width="" height="" src="../assets/images/post21/trivy-custom-resources.png" /></p>

<p>For example to view vulnerabilities report from all namespaces we can run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl get vulnerabilityreports.aquasecurity.github.io <span class="nt">-A</span> <span class="nt">-owide</span>
</code></pre></div></div>

<p>This results in to the following output:</p>

<p><img align="center" width="" height="" src="../assets/images/post21/trivy-vulnerability-report.png" /></p>

<p>To view more details we can use:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl describe vulnerabilityreports.aquasecurity.github.io -n &lt;&lt;namespace&gt;&gt;
</code></pre></div></div>

<ol>
  <li>Second approach is to install <a href="https://k8slens.dev/">Lens</a> which is super cool Kubernetes IDE. Lens is a desktop application which allows you
to manage Kubernetes clusters. It is available for Windows, MacOS and Linux. Once you install Lens you can connect to your cluster and see and manage all the resources in the cluster. Lens has an extensions for
tryvi-operator which allows you to see the results of the scan in it’s UI.</li>
</ol>

<p>You can see the results of the scan as a combined report:</p>

<p><img align="center" width="" height="" src="../assets/images/post21/lens-combined-report.png" /></p>

<p>Or if you click on the pod you can see the vulnerabilities for the specific pod:</p>

<p><img align="center" width="" height="" src="../assets/images/post21/lens-pod-view.png" /></p>

<p>If you have not tried Lens yet, I highly recommend it. It is awesome and very useful tool.</p>

<ol>
  <li>Third method is to use <a href="https://prometheus.io/">Prometheus</a> to scrape metrics from <code class="language-plaintext highlighter-rouge">trivy-operator</code> and visualize them in Grafana.</li>
</ol>

<p>To use this option make sure you have enabled <code class="language-plaintext highlighter-rouge">serviceMonitor</code> and <code class="language-plaintext highlighter-rouge">operator.metricsVulnIdEnabled</code> in <code class="language-plaintext highlighter-rouge">trivy-operator-values.yaml</code>. After this save and import the following <a href="https://drive.google.com/file/d/1BvbQc3QCSVeiRMjHaqmPO0691XhPf_Ef/view?usp=sharing">json</a> dashboard into Grafana.</p>

<p>As result you will able to see the following dashboard:</p>

<p><img align="center" width="" height="" src="../assets/images/post21/grafana-dashboard.png" /></p>

<p>That’s it! We have deployed <code class="language-plaintext highlighter-rouge">trivy-operator</code> and it is scanning our cluster. We also can see results of the scan using different tools.
Definitely <code class="language-plaintext highlighter-rouge">trivy-operator</code> is a great tool to scan your cluster and find vulnerabilities in your workloads.
I hope this post was helpful and you will find it useful.</p>

<p>See you 🤜 🤛, bye!</p>]]></content><author><name>Andrej Trusevic</name></author><category term="Microsoft" /><category term="AzureCLI" /><category term="Script" /><category term="Helm" /><category term="YAML" /><category term="AKS" /><category term="K8S" /><category term="Kubernetes" /><category term="Deployments" /><category term="Bash" /><category term="Demo" /><category term="Docker" /><category term="Linux" /><category term="Pod" /><category term="Trivy" /><category term="Trivy-Operator" /><category term="Aquasec" /><category term="CVE" /><category term="Vulnerability" /><category term="Security" /><category term="Lens" /><category term="Containers" /><category term="Grafana" /><category term="Prometheus" /><summary type="html"><![CDATA[Check your cluster security state with Trivy-operator]]></summary></entry></feed>