pre-review hardening: technical qualifiers across all courses, git/Argo CD proper nouns, a11y (skip link, main, focus-visible, reduced-motion net, textContent legends), perf (intrinsic image dims, decoding async, lazy scenes + will-change lifecycle), nginx ^~ /assets/ priority, OG/canonical/build-revision, preflight gates

This commit is contained in:
2026-08-17 19:51:12 +10:00
parent b41564d462
commit 50993bedec
9 changed files with 358 additions and 233 deletions
+2
View File
@@ -7,6 +7,8 @@
# verified and cached in the zot pull-through mirror, so this layer costs nothing # verified and cached in the zot pull-through mirror, so this layer costs nothing
# extra. Only bash/sed/find/install are used here — node itself is incidental. # extra. Only bash/sed/find/install are used here — node itself is incidental.
FROM node:22-bookworm-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS build FROM node:22-bookworm-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS build
ARG BUILD_REV=dev
ENV BUILD_REV=$BUILD_REV
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN bash build.sh RUN bash build.sh
+2 -2
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="660" viewBox="0 0 1180 660" role="img" aria-label="End-to-end delivery flow in four lanes converging on one running workload. Change lane: a commit lands in the GitHub repository, ArgoCD renders and diffs it against the live cluster and syncs the difference - the cluster pulls, nothing pushes. Shape lane: the Helm chart plus a per-cluster values context passes the schema gate, which refuses to render if a required value is missing, and produces the manifests ArgoCD applies. Artefact lane: CI builds once and pushes to Azure Container Registry; a central pull-through mirror in the cloud fronts upstream, per-site mirrors pull from the centre, and each node pulls by digest - the load fans out in tiers so no thundering herd ever reaches upstream. Secret lane: Azure Key Vault holds the values, the External Secrets operator syncs them into the cluster, and git only ever holds the reference. The workload that runs is the sum of all four: synced state, rendered shape, digest-pinned image, injected secret."> <svg xmlns="http://www.w3.org/2000/svg" width="1180" height="660" viewBox="0 0 1180 660" role="img" aria-label="End-to-end delivery flow in four lanes converging on one running workload. Change lane: a commit lands in the GitHub repository, Argo CD renders and diffs it against the live cluster and syncs the difference - the cluster pulls, nothing pushes. Shape lane: the Helm chart plus a per-cluster values context passes the schema gate, which refuses to render if a required value is missing, and produces the manifests Argo CD applies. Artefact lane: CI builds once and pushes to Azure Container Registry; a central pull-through mirror in the cloud fronts upstream, per-site mirrors pull from the centre, and each node pulls by digest - the load fans out in tiers so no thundering herd ever reaches upstream. Secret lane: Azure Key Vault holds the values, the External Secrets operator syncs them into the cluster, and git only ever holds the reference. The workload that runs is the sum of all four: synced state, rendered shape, digest-pinned image, injected secret.">
<defs> <defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"></path> <path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"></path>
@@ -32,7 +32,7 @@
<rect x="270" y="136" width="180" height="48" rx="10" fill="none" stroke="#64748b" stroke-width="1.5"></rect> <rect x="270" y="136" width="180" height="48" rx="10" fill="none" stroke="#64748b" stroke-width="1.5"></rect>
<text x="360" y="165" text-anchor="middle">GitHub repo</text> <text x="360" y="165" text-anchor="middle">GitHub repo</text>
<rect x="510" y="136" width="230" height="48" rx="10" fill="#0c8fce" fill-opacity="0.06" stroke="#0c8fce" stroke-width="2"></rect> <rect x="510" y="136" width="230" height="48" rx="10" fill="#0c8fce" fill-opacity="0.06" stroke="#0c8fce" stroke-width="2"></rect>
<text x="625" y="165" text-anchor="middle" fill="#0c8fce">ArgoCD: render + diff + sync</text> <text x="625" y="165" text-anchor="middle" fill="#0c8fce">Argo CD: render + diff + sync</text>
</g> </g>
<g stroke="#64748b" stroke-width="1.5" marker-end="url(#arr)"> <g stroke="#64748b" stroke-width="1.5" marker-end="url(#arr)">
<line x1="210" y1="160" x2="264" y2="160"></line> <line x1="210" y1="160" x2="264" y2="160"></line>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+6 -3
View File
@@ -18,14 +18,17 @@ server {
include /etc/nginx/security-headers.conf; include /etc/nginx/security-headers.conf;
# ---- Caching ------------------------------------------------------------- # ---- Caching -------------------------------------------------------------
# Scene images are content-hashed at build time → safe to cache hard. # Everything under /assets/ is content-fingerprinted at build time -> cache hard.
location /assets/ { # `^~` gives this prefix priority over the regex `.js` location below, so fingerprinted
# JavaScript gets the immutable policy rather than no-cache.
location ^~ /assets/ {
include /etc/nginx/security-headers.conf; include /etc/nginx/security-headers.conf;
expires 1y; expires 1y;
add_header Cache-Control "public, immutable" always; add_header Cache-Control "public, immutable" always;
} }
# Scripts are NOT fingerprinted yet — revalidate so a deploy actually propagates. # Any JS OUTSIDE /assets/ (there is none today) revalidates; fingerprinted app JS is
# handled immutably by the ^~ /assets/ block above.
location ~* \.js$ { location ~* \.js$ {
include /etc/nginx/security-headers.conf; include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache" always; add_header Cache-Control "no-cache" always;
+227 -197
View File
@@ -7,10 +7,10 @@
<p class="scrollcue" aria-hidden="true">scroll<span></span></p> <p class="scrollcue" aria-hidden="true">scroll<span></span></p>
</header> </header>
<nav class="toc" aria-label="index"> <nav class="toc" aria-label="Course index">
<p class="kt">Index</p> <p class="kt">Index</p>
<ol> <ol>
<li><a href="#c0"><b>Course 00</b><span>Aperitif - three commands, and what actually just happened</span></a></li> <li><a href="#c0"><b>Course 00</b><span>Aperitif - three delivery actions and one check</span></a></li>
<li><a href="#c1"><b>Course I</b><span>Podman - an image is a stack of frozen diffs</span></a></li> <li><a href="#c1"><b>Course I</b><span>Podman - an image is a stack of frozen diffs</span></a></li>
<li><a href="#c2"><b>Course II</b><span>Kubernetes - a cluster is a promise, not a place</span></a></li> <li><a href="#c2"><b>Course II</b><span>Kubernetes - a cluster is a promise, not a place</span></a></li>
<li><a href="#c3a"><b>Course IIIa</b><span>The node - where intent becomes a process</span></a></li> <li><a href="#c3a"><b>Course IIIa</b><span>The node - where intent becomes a process</span></a></li>
@@ -27,12 +27,12 @@
</nav> </nav>
<section class="purpose" aria-labelledby="purph"> <section class="purpose" aria-labelledby="purph">
<p class="kt" id="purph">Purpose of this document</p> <h2 class="kt" id="purph">What this teaches</h2>
<p>The Exploded Cluster teaches how modern container platforms work by taking them apart - <p>The Exploded Cluster teaches how modern container platforms work by taking them apart -
literally. Each course is one machine drawn as a single exploded illustration, sliced into its literally. Each course is one machine drawn as a single exploded illustration, sliced into its
real components and wired to your scroll, so the architecture moves while the words explain it. real components and wired to your scroll, so the architecture moves while the words explain it.
Start with the aperitif&#39;s three terminal commands; finish knowing how a change travels from a Start with the aperitif&#39;s four terminal commands - three that deliver, one that checks - and
git commit to a running, secret-fed, digest-pinned workload on a fleet. The library at the end links only to official documentation, so every claim here can be finish knowing how a change travels from a Git commit to a running, secret-fed, digest-pinned workload on a fleet. The library at the end links only to official documentation, so every claim here can be
checked against its source.</p> checked against its source.</p>
</section> </section>
@@ -41,7 +41,7 @@
<section class="course" id="c0" data-scene="surround" aria-labelledby="c0h"> <section class="course" id="c0" data-scene="surround" aria-labelledby="c0h">
<header class="chead"> <header class="chead">
<p class="eyebrow">Course 00 &middot; Aperitif</p> <p class="eyebrow">Course 00 &middot; Aperitif</p>
<h2 id="c0h" class="thesis">Three commands.<br><em>What actually just happened?</em></h2> <h2 id="c0h" class="thesis">Three delivery actions. One check.<br><em>What actually just happened?</em></h2>
<p class="lede">The whole ceremony of shipping software fits in four lines of terminal. They <p class="lede">The whole ceremony of shipping software fits in four lines of terminal. They
work on your first day and stay mysterious for years. Scroll - the shell comes off first.</p> work on your first day and stay mysterious for years. Scroll - the shell comes off first.</p>
</header> </header>
@@ -74,9 +74,10 @@
<span class="mono">kubectl</span> in the docs, type whichever your cluster gives you.</p> <span class="mono">kubectl</span> in the docs, type whichever your cluster gives you.</p>
<p>That <span class="mono">1/1</span> reads as containers-ready over containers-wanted: a pod <p>That <span class="mono">1/1</span> reads as containers-ready over containers-wanted: a pod
can hold more than one, which is Course IIIb.</p> can hold more than one, which is Course IIIb.</p>
<p>An image got <b>built</b> - of what, exactly? <b>Pushed</b> - to where, and what travelled? <p>Three of those were delivery actions - <b>build</b>, <b>push</b>, <b>apply</b> - and the
<b>Applied</b> - which is not the same as launched. <b>Running</b> - according to whom? last, <span class="mono">oc get pods</span>, only checked the result. Built of what, exactly?
Every course below takes one of those words apart. The armour is already loose.</p> Pushed to where, and what travelled? Applied, which is not the same as launched. Running,
according to whom? Each course below takes one of those words apart.</p>
</div> </div>
</section> </section>
@@ -124,9 +125,9 @@
<h3>What an image is made of</h3> <h3>What an image is made of</h3>
<p>An image is not a copy of a machine. It is a stack of <b>read-only</b> layers, each <p>An image is not a copy of a machine. It is a stack of <b>read-only</b> layers, each
recording only what changed from the one beneath. Layers are content-addressed, so an recording only what changed from the one beneath. Layers are content-addressed, so an
identical layer is stored once and reused by every image that references it, so a pull only identical layer is stored once and reused by every image that references it, so a pull
fetches the layers you do not already have. A base sits at the bottom, your dependencies on it, fetches just the layers you do not already have. A base sits at the bottom, your dependencies on it,
your code - usually the smallest layer, always the most volatile - above that.</p> your code - usually the smallest layer, and typically the most volatile - above that.</p>
<p>Those layers become one filesystem through a <b>union mount</b> - overlayfs, the same <p>Those layers become one filesystem through a <b>union mount</b> - overlayfs, the same
kernel feature you can mount by hand. The read-only image layers are the lower dirs; the kernel feature you can mount by hand. The read-only image layers are the lower dirs; the
container gets a fresh <b>upper dir</b> of its own. Writes land in the upper, and editing an container gets a fresh <b>upper dir</b> of its own. Writes land in the upper, and editing an
@@ -144,10 +145,9 @@
happens to build software.</p> happens to build software.</p>
<p class="fieldnote"><b>Field note.</b> A rebuild that takes twenty minutes and one that <p class="fieldnote"><b>Field note.</b> A rebuild that takes twenty minutes and one that
takes twenty seconds are usually the same Containerfile with the lines swapped.</p> takes twenty seconds are usually the same Containerfile with the lines swapped.</p>
<p class="closeline">This stack explains the whole ecosystem above it: sharing explains why <p class="closeline">This stack explains a lot of what sits above it: shared layers make
pulls are fast, immutability explains why a digest can name the exact bytes (Course VII), pulls fast, immutable content lets a digest name exact bytes (Course VII), and the throwaway
and the throwaway top layer explains why state needs volumes. One idea - frozen diffs - top layer is why persistent state needs volumes.</p>
all the way down.</p>
<p class="reads"><b>Pre-reads:</b> none - start here <p class="reads"><b>Pre-reads:</b> none - start here
&ensp;<b>Further:</b> <a href="https://podman.io/get-started">Podman get-started</a> &middot; &ensp;<b>Further:</b> <a href="https://podman.io/get-started">Podman get-started</a> &middot;
<a href="https://docs.podman.io/">Podman docs + builds</a></p> <a href="https://docs.podman.io/">Podman docs + builds</a></p>
@@ -160,7 +160,7 @@
<p class="eyebrow">Course II &middot; Kubernetes</p> <p class="eyebrow">Course II &middot; Kubernetes</p>
<h2 id="c2h" class="thesis">A cluster is a promise,<br><em>not a place.</em></h2> <h2 id="c2h" class="thesis">A cluster is a promise,<br><em>not a place.</em></h2>
<p class="lede">You never tell Kubernetes <i>how</i> to run your app. You describe what you <p class="lede">You never tell Kubernetes <i>how</i> to run your app. You describe what you
want - declarative intent - and the cluster works, forever, to make it true. Scroll, and want - declarative intent - and the cluster works continuously to make it true. Scroll, and
the formation splits: the half that decides rises, the halves that run spread below.</p> the formation splits: the half that decides rises, the halves that run spread below.</p>
</header> </header>
<div class="pin"> <div class="pin">
@@ -178,8 +178,7 @@
<h3>Desired against actual, on a loop</h3> <h3>Desired against actual, on a loop</h3>
<p>The habit underneath everything: the <b>reconciliation loop</b> - compare desired state <p>The habit underneath everything: the <b>reconciliation loop</b> - compare desired state
against actual state, fix the difference, repeat. That <span class="mono">oc apply</span> against actual state, fix the difference, repeat. That <span class="mono">oc apply</span>
didn't launch anything; it filed paperwork. The machine took it from there, and it never didn't launch anything; it filed paperwork. The machine took it from there, and it keeps taking it from there: kill a pod and it returns, not because something noticed the
stops taking it from there: kill a pod and it returns, not because something noticed the
crash but because the loop noticed the difference.</p> crash but because the loop noticed the difference.</p>
<p class="sayit">Kubernetes doesn't run your app - it reconciles it.</p> <p class="sayit">Kubernetes doesn't run your app - it reconciles it.</p>
<h3>One half decides, one half runs</h3> <h3>One half decides, one half runs</h3>
@@ -187,28 +186,30 @@
plane</b> that decides - holds the truth, schedules, reconciles - and <b>worker nodes</b> plane</b> that decides - holds the truth, schedules, reconciles - and <b>worker nodes</b>
that run pods. The workers are deliberately interchangeable: identical, replaceable, cattle that run pods. The workers are deliberately interchangeable: identical, replaceable, cattle
from day one. Authority does not live where the work happens.</p> from day one. Authority does not live where the work happens.</p>
<p class="fieldnote"><b>Field note.</b> If you are SSHing into nodes to "fix" things, you <p class="fieldnote"><b>Field note.</b> If you SSH into a node to "fix" a workload, a
are arm-wrestling the reconciler - and it does not get tired. Change the desired state controller may reconcile your change away. Change the desired state instead, and let the loop
instead.</p> carry it.</p>
<h3>What the control plane is made of</h3> <h3>What the control plane is made of</h3>
<p>"Control plane" is four processes and a database, and naming them makes every later error <p>The core control plane is the <b>API server</b>, <b>scheduler</b> and
message readable. The <b>api-server</b> is the only door: everything authenticates, is <b>controller-manager</b>, backed by <b>etcd</b>; cloud deployments may also run a
authorised and is admitted there, and it is the only component allowed to touch <b>etcd</b> - cloud-controller-manager. Naming them makes the later error messages readable. The API server
the key-value store holding the entire cluster state. Lose etcd and you have lost the (<span class="mono">kube-apiserver</span>) is the only door: requests authenticate, are
cluster, which is why backing it up is the homework nobody should skip. The <b>scheduler</b> authorised and are admitted there, and it is the one component that talks to <b>etcd</b>, the
decides which node a new pod belongs on - packing by the resources a pod key-value store holding Kubernetes API state. Lose etcd and you have lost the cluster, which
<i>requests</i>, not by what it currently uses - and writes that decision down; it never is why backing it up is the homework to not skip. The scheduler decides which node a new pod
starts anything. The <b>controller-manager</b> runs the reconciliation loops. Nothing talks belongs on - packing by the resources a pod <i>requests</i>, not by what it currently uses -
sideways - every component watches the api-server.</p> and records that decision; it does not start containers. The controller-manager runs the
reconciliation loops. Control-plane state converges through the API server: the scheduler and
built-in controllers watch API objects and write decisions back through the API rather than
modifying etcd directly.</p>
<p>One of those loops is the chain you will debug most: a <b>Deployment</b> creates a <p>One of those loops is the chain you will debug most: a <b>Deployment</b> creates a
<b>ReplicaSet</b>, and the ReplicaSet creates <b>pods</b>. That is why the deploy in the <b>ReplicaSet</b>, and the ReplicaSet creates <b>pods</b>. That is why the deploy in the
aperitif printed <span class="mono">deployment.apps/api created</span> and you then went aperitif printed <span class="mono">deployment.apps/api created</span> and you then went
looking for a pod - and why, when a rollout is stuck with no pod at all, the answer is looking for a pod - and why, when a rollout is stuck with no pod at all, the answer is
upstream in that chain rather than on any node.</p> upstream in that chain rather than on any node.</p>
<p class="sayit">etcd is the truth. Everything else is a cache of it.</p> <p class="sayit">etcd is the durable store for Kubernetes API state; controllers reconcile from it continually.</p>
<p class="closeline">Everything the delivery arc teaches from Course VI onward is the same <p class="closeline">Everything the delivery arc teaches from Course VI onward is the same
loop at a bigger scale - git as the desired state, whole fleets as reconciled objects. loop at a larger scale: Git as the desired state, whole fleets as reconciled objects.</p>
Learn the promise once; it repeats all the way up.</p>
<p class="reads"><b>Pre-reads:</b> C-I <p class="reads"><b>Pre-reads:</b> C-I
&ensp;<b>Further:</b> <a href="https://kubernetes.io/docs/concepts/overview/">Kubernetes overview</a> &middot; &ensp;<b>Further:</b> <a href="https://kubernetes.io/docs/concepts/overview/">Kubernetes overview</a> &middot;
<a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a></p> <a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a></p>
@@ -236,21 +237,21 @@
</div> </div>
<div class="prose"> <div class="prose">
<h3>Where a pod becomes processes</h3> <h3>Where a pod becomes processes</h3>
<p>Every node runs a <b>kubelet</b> - the agent that owns what should be running there. It does <p>The <b>scheduler</b> decides which node a pod belongs on; the <b>kubelet</b> on that node
not create containers itself. It speaks <b>CRI</b> - the Container Runtime Interface - to <b>containerd</b> or CRI-O, and that makes the assigned PodSpec real; the <b>runtime</b> creates and starts the containers. The
runtime pulls the image (through the mirror of Course VII) and actually creates and starts kubelet does not place pods, and does not itself create containers - it speaks <b>CRI</b> (the
the container, handing the low-level work to runc or crun. A <b>CNI plugin</b> (Container Network Interface) hands the pod Container Runtime Interface) to <b>containerd</b> or CRI-O, and that runtime pulls the image
a real IP, and <b>kube-proxy</b> - or an eBPF datapath (code running (through the mirror of Course VII) and starts it, handing the low-level work to runc or crun.
inside the kernel itself) replacing it - makes Service A <b>CNI plugin</b> (Container Network Interface) gives the pod a real IP - called by the
addresses route to real pods. One correction worth carrying: the runtime calls the CNI plugin, runtime, not the kubelet - and <b>kube-proxy</b>, or an eBPF datapath replacing it, makes
not the kubelet.</p> Service addresses route to real pods.</p>
<p class="sayit">The kubelet decides what should run. The runtime is what starts it.</p> <p class="sayit">The scheduler places the pod. The kubelet realises it. The runtime runs it.</p>
<p class="fieldnote"><b>Field note.</b> Node NotReady? The kubelet is a systemd unit <p class="fieldnote"><b>Field note.</b> Node NotReady? The kubelet is a systemd unit
like any other: <span class="mono">journalctl -u kubelet</span> on that node, and check it like any other: <span class="mono">journalctl -u kubelet</span> on that node, and check it
can still reach the api-server. A node that cannot phone home is presumed lost.</p> can still reach the api-server. A node that cannot phone home is presumed lost.</p>
<p class="closeline">The control plane never touches your workload. It writes intent; the <p class="closeline">The control plane does not start container processes directly. It records
kubelet turns intent into instructions, and the runtime turns instructions into processes. Authority and execution meet exactly here, nowhere and reconciles intent; node-side components execute it - the kubelet turning intent into
else.</p> instructions, the runtime turning instructions into processes.</p>
<p class="reads"><b>Pre-reads:</b> C-II <p class="reads"><b>Pre-reads:</b> C-II
&ensp;<b>Further:</b> <a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a> &middot; &ensp;<b>Further:</b> <a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a> &middot;
<a href="https://kubernetes.io/docs/concepts/architecture/#node-components">node components</a></p> <a href="https://kubernetes.io/docs/concepts/architecture/#node-components">node components</a></p>
@@ -280,9 +281,10 @@
<h3>The jacket, not the container</h3> <h3>The jacket, not the container</h3>
<p>Everything inside the jacket shares a <b>network namespace</b>: one IP, localhost between <p>Everything inside the jacket shares a <b>network namespace</b>: one IP, localhost between
friends. Volumes are declared once on the pod, but each container mounts the ones it needs - friends. Volumes are declared once on the pod, but each container mounts the ones it needs -
sharing storage is opt-in, not automatic. <b>initContainers</b> run first, in order, each to sharing storage is opt-in, not automatic. regular <b>init containers</b> run in order and
completion - nothing else in the pod starts until every one of them has exited successfully. <b>Sidecars</b> ride along with their own containers and their complete before the app containers start. <b>Native sidecars</b> are the deliberate exception:
own jobs: proxy, logs, reload.</p> restartable init containers that keep running alongside the app, for jobs like proxy, logs and
config reload.</p>
<h3>Three probes, three different jobs</h3> <h3>Three probes, three different jobs</h3>
<p><b>startup</b> owns warm-up, <b>readiness</b> gates traffic, <b>liveness</b> restarts the <p><b>startup</b> owns warm-up, <b>readiness</b> gates traffic, <b>liveness</b> restarts the
truly hung. Confusing them is how healthy pods get executed - a slow start killed by an truly hung. Confusing them is how healthy pods get executed - a slow start killed by an
@@ -296,7 +298,7 @@
the number alone: <span class="mono">oc describe pod &lt;name&gt;</span> shows both together, the number alone: <span class="mono">oc describe pod &lt;name&gt;</span> shows both together,
and <span class="mono">oc logs --previous</span> shows what the dead container said on its and <span class="mono">oc logs --previous</span> shows what the dead container said on its
way out. Worth connecting to what you already know: a memory limit becomes a way out. Worth connecting to what you already know: a memory limit becomes a
<b>cgroup ceiling</b>, and the kernel's OOM killer enforces it exactly as it would for any <b>cgroup ceiling</b>, and the kernel's OOM killer enforces it just as it would for any
other process on the box.</p> other process on the box.</p>
<p class="closeline">The pod is the smallest schedulable unit - the jacket, never the <p class="closeline">The pod is the smallest schedulable unit - the jacket, never the
container. Once that distinction lands, half of Kubernetes networking stops being container. Once that distinction lands, half of Kubernetes networking stops being
@@ -341,26 +343,30 @@
everything here finds everything else, from a Service picking pods to a fleet hub picking everything here finds everything else, from a Service picking pods to a fleet hub picking
whole clusters (Course VI). Wear the label and you are eligible; readiness decides whether whole clusters (Course VI). Wear the label and you are eligible; readiness decides whether
you stay.</p> you stay.</p>
<p>And the ClusterIP is worth a Linux translation: <b>no interface owns that address</b>. <p>A <b>ClusterIP</b> is a virtual Service address implemented by the node dataplane rather
Nothing answers ARP for it. It exists as a rule - <b>kube-proxy</b> programs iptables (or than an application process listening on that IP. In iptables or nftables mode,
IPVS) on every node so packets aimed at the virtual IP are DNATed to one of the ready pod <b>kube-proxy</b> installs rules that steer Service traffic to endpoint IPs; if you have
IPs, and an eBPF dataplane does the same job further down without the rule tables. Cluster written a DNAT rule by hand, that mode will feel familiar. In IPVS mode, kube-proxy binds
DNS - <b>CoreDNS</b> - resolves <span class="mono">api.myns.svc.cluster.local</span> to that Service IPs to the <span class="mono">kube-ipvs0</span> dummy interface and creates IPVS
VIP. If you have ever written a DNAT rule by hand, you have already built a small Service.</p> virtual servers. eBPF implementations can replace kube-proxy with their own dataplane. Cluster
DNS (<b>CoreDNS</b>) resolves <span class="mono">api.myns.svc.cluster.local</span> to the
Service address.</p>
<h3>Readiness decides membership</h3> <h3>Readiness decides membership</h3>
<p>A pod failing its readiness probe <b>silently leaves the pool</b>. No error, no event at <p>When a configured readiness probe fails, the pod becomes <b>unready</b> and normal
the client - traffic simply stops arriving. That is the feature: broken instances remove Kubernetes Service traffic stops selecting it for new connections. There is no error at the
themselves. It is also the first place to look when traffic "disappears".</p> client - the pod drops out of the endpoint set. That is the feature: broken instances take
themselves out of rotation. It is also the first place to look when traffic "disappears".</p>
<p>The rule has deliberate exceptions: you address pods directly when debugging a specific <p>The rule has deliberate exceptions: you address pods directly when debugging a specific
instance, and headless Services exist precisely so StatefulSet members can be reached instance, and headless Services exist precisely so StatefulSet members can be reached
individually by stable DNS name. For ordinary application traffic, though, the Service is individually by stable DNS name. For ordinary application traffic, though, the Service is
the only address worth knowing.</p> the only address worth knowing.</p>
<p class="sayit">For application traffic: never talk to a pod; talk to a Service.</p> <p class="sayit">For ordinary Service traffic: address the Service, not a pod.</p>
<p class="fieldnote"><b>Field note.</b> "The network is broken" after a deploy is usually <p class="fieldnote"><b>Field note.</b> "The network is broken" after a deploy is usually
readiness telling the truth about your app - not the network lying about your packets.</p> readiness telling the truth about your app - not the network lying about your packets.</p>
<p class="closeline">The dark pod in the scene is not an error state - it is the system <p class="closeline">The dark pod in the scene is not an error state - it is the system
working. Membership is re-earned every few seconds, by every pod, for as long as it serves.</p> working. For pods with readiness probes, eligibility for Service traffic is continually
re-evaluated for the life of the pod.</p>
<p class="reads"><b>Pre-reads:</b> C-III's pod scene (readiness lives there) <p class="reads"><b>Pre-reads:</b> C-III's pod scene (readiness lives there)
&ensp;<b>Further:</b> <a href="https://kubernetes.io/docs/concepts/services-networking/">Services and networking</a></p> &ensp;<b>Further:</b> <a href="https://kubernetes.io/docs/concepts/services-networking/">Services and networking</a></p>
</div> </div>
@@ -388,24 +394,27 @@
<div class="prose"> <div class="prose">
<h3>The doorman interviews every pod</h3> <h3>The doorman interviews every pod</h3>
<p>The <b>SCC</b> - Security Context Constraint - is admission deciding what a pod may BE, checked <p>The <b>SCC</b> - Security Context Constraint - is admission deciding what a pod may BE, checked
by the api-server when the pod is created and before any node sees it. The default, restricted-v2, runs by the api-server when the pod is created and before any node sees it. Under OpenShift's restricted
your container as a <b>random non-root UID</b> - your image has to cope. Workloads that SCCs, workloads normally run as a <b>non-root UID drawn from the project's allocated range</b>,
genuinely need privilege get a dedicated ServiceAccount bound to a minimal custom SCC, so an image has to work with an arbitrary permitted UID rather than assuming a fixed user.
never the stock one.</p> Workloads that genuinely need privilege get a dedicated ServiceAccount bound to the minimum
SCC that grants it - not the stock one.</p>
<h3>The platform runs itself</h3> <h3>The platform runs itself</h3>
<p><b>Routes</b> predate Ingress and still rule here. An <b>Operator</b> is a controller paired with a custom <p><b>Routes</b> predate Ingress and still rule here. An <b>Operator</b> is a controller paired with a
resource: you describe what you want in YAML, and its controller builds it and keeps it true custom resource: you describe what you want in YAML, and its controller builds it and keeps it
- the reconciliation loop of C-II, sold as a product. The split matters: the platform's own operators are driven by the Cluster Version true - an Operator applies the same reconciliation pattern to an application or platform
capability. The split matters: the platform's own operators are driven by the Cluster Version
Operator, while <b>OLM</b> installs and upgrades the add-on Operators you choose from Operator, while <b>OLM</b> installs and upgrades the add-on Operators you choose from
OperatorHub. The OS underneath is immutable - changed by <b>MachineConfig</b>, never by SSH. OperatorHub. <b>MachineConfig</b> is the supported declarative path for node OS configuration;
And <b>SNO</b> - single-node OpenShift - puts the whole cluster on one box at the edge. At fleet scale the labels ad-hoc SSH changes create drift and are not the intended operating model. And <b>SNO</b> -
single-node OpenShift - puts a whole cluster on one box at the edge. At fleet scale the labels
from Course VI decide which of these boxes runs what.</p> from Course VI decide which of these boxes runs what.</p>
<p class="sayit">On OpenShift, admission is the interview - the SCC is the dress code.</p> <p class="sayit">On OpenShift, admission is the interview - the SCC is the dress code.</p>
<p class="fieldnote"><b>Field note.</b> Deployment stuck at 0/1 with no pod at all? The <p class="fieldnote"><b>Field note.</b> Deployment stuck at 0/1 with no pod at all? The
refusal happened above scheduling - read the ReplicaSet events. The error lives a level refusal happened above scheduling - read the ReplicaSet events. The error lives a level
up.</p> up.</p>
<p class="closeline">Everything in the ring wraps the same core you already know. What <p class="closeline">Everything in the ring wraps the same core you already know; what
OpenShift adds is a house style with teeth - and admission is where it bites first.</p> OpenShift adds is admission, routing and lifecycle opinions on top of it.</p>
<p class="reads"><b>Pre-reads:</b> C-II <p class="reads"><b>Pre-reads:</b> C-II
&ensp;<b>Further:</b> <a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift documentation</a> &middot; &ensp;<b>Further:</b> <a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift documentation</a> &middot;
<a href="https://www.redhat.com/en/technologies/cloud-computing/openshift">Red Hat OpenShift</a></p> <a href="https://www.redhat.com/en/technologies/cloud-computing/openshift">Red Hat OpenShift</a></p>
@@ -420,7 +429,7 @@
<p class="lede">The mental model everyone arrives with: someone with credentials pushes manifests <p class="lede">The mental model everyone arrives with: someone with credentials pushes manifests
at the cluster. In the architecture this course teaches, nothing is pushed: a repository holds at the cluster. In the architecture this course teaches, nothing is pushed: a repository holds
the desired state, an agent inside the cluster watches it, and the cluster pulls its own future the desired state, an agent inside the cluster watches it, and the cluster pulls its own future
from git.</p> from Git.</p>
</header> </header>
<div class="pin"> <div class="pin">
<div class="viewport"> <div class="viewport">
@@ -436,51 +445,52 @@
<div class="prose"> <div class="prose">
<h3>The loop you already know, one level up</h3> <h3>The loop you already know, one level up</h3>
<p>C-II taught the reconciliation loop: desired versus actual, fix the difference, repeat. <p>C-II taught the reconciliation loop: desired versus actual, fix the difference, repeat.
ArgoCD is the same habit applied to <b>delivery</b>. An Application names a repo, a path and a <b>Argo CD</b> applies the same habit to <b>delivery</b>. An Application names a repo, a path
revision - watch this branch of this repository - and the controller renders what it finds and a revision - watch this branch of this repository - and the controller renders what it
there, compares it against the live cluster, and syncs the difference. The deploy button is a finds there, compares it against the live cluster, and syncs the difference. The deploy button
git commit; the change history is git log; code review <i>is</i> change control. Git is the is a Git commit; the change history is <span class="mono">git log</span>; code review <i>is</i>
record of <i>intent</i> - the cluster's own audit log and the reconciler's sync history still change control. Git records <i>intent</i> - the cluster's own audit log and Argo CD's sync
record what actually happened, including everything git never sees.</p> history record what actually happened, including what Git does not see.</p>
<p class="sayit">oc is for archaeology. git is for change.</p> <p class="sayit">oc reads the running system; Git records the change.</p>
<h3>Pull, not push - the security inversion</h3> <h3>Pull, not push - the security inversion</h3>
<p>Here the cluster <b>pulls</b>. Push-based delivery exists and is still GitOps to many - this <p>Here the cluster <b>pulls</b>. Push-based delivery exists and is still GitOps to many; the
is the stronger variant, and worth choosing deliberately: no CI system, no laptop, no build pull variant is worth choosing deliberately, because no CI system, laptop or build pipeline
pipeline holds a credential that can touch the cluster, because the agent inside holds a holds a credential that can touch the cluster - the agent inside holds a read-only deploy key
read-only deploy key and the trust arrow points out. Compromise the build system and you can and the trust arrow points out. Compromise the build system and you can propose a change,
propose a change, which is visible; you cannot reach into production <i>through git</i>. It which is visible; you cannot reach into production <i>through Git</i>. It can still push
can still push images - which is the other half of why a manifest should name the digest, not images, which is the other half of why a manifest should name the digest, not the tag.
the tag. Hand-edit a live object Hand-edit a live object and the controller flags it OutOfSync. <b>With self-heal enabled</b>,
and the controller flags it OutOfSync - <b>with selfHeal enabled</b> it puts the object back, Argo CD can restore the desired state; <b>with prune enabled</b>, objects removed from Git can
and <b>with prune enabled</b> what leaves git leaves the cluster. Both are opt-in: without be deleted. Both are opt-in: without them the reconciler reports the drift and waits for a
them the reconciler reports the drift and waits for a human. Rollback is <span class="mono">git revert</span>, sync. Rollback is <span class="mono">git revert</span>, which is why commit hygiene is an
which is why commit hygiene is an operational skill.</p> operational skill.</p>
<p class="fieldnote"><b>Field note.</b> A hand-patch on a live object survives exactly until <p class="fieldnote"><b>Field note.</b> With self-heal enabled, a live hand-patch may be
the next sync. If you must touch production directly to stop the bleeding, open the pull reverted on the next reconciliation. Without self-heal, Argo CD reports the drift until someone
request in the same hour - otherwise the reconciler quietly undoes your fix, and the outage syncs it. Either way, land the change in Git in the same hour, or the fix and the reason for it
returns with nobody able to say why.</p> go missing.</p>
<h3>At fleet scale, the label is the deploy button</h3> <h3>At fleet scale, the label is the deploy button</h3>
<p>Run many OpenShift clusters under a hub - RHACM, Red Hat's fleet manager, with edge clusters <p>Run many OpenShift clusters under a hub - RHACM, Red Hat's fleet manager, with edge clusters
arriving through zero-touch provisioning - and nobody applies apps to clusters by hand. Each app carries a arriving through zero-touch provisioning - and nobody applies apps to clusters by hand. Each app carries a
<b>Placement</b> that selects cluster <b>labels</b>; the hub matches placements against the <b>Placement</b> that selects cluster <b>labels</b>; the hub matches placements against the
labels a cluster wears, and the chosen cluster is given its assignment - push-style from the labels a cluster wears, and the chosen cluster is given its assignment - push-style from the
hub by default, or in a pull model where each cluster runs its own reconciler, which is the hub by default, or in a pull model where each cluster runs its own reconciler. Labelling a
variant this arc teaches. Either way, labelling the cluster <i>is</i> the deploy action: attach the label and the app follows, remove it and the app cluster changes its placement eligibility; the lifecycle and cleanup of what lands still follow
leaves. The same pull model as above, one level bigger - a cluster's labels are its the configured propagation and deletion policy, so removing a label does not, on its own,
entitlements, reconciled like everything else.</p> guarantee an app is deleted.</p>
<p class="sayit">Label the cluster; the app follows.</p> <p class="sayit">Label the cluster; the app follows.</p>
<h3>The one thing git never holds</h3> <h3>Keep plaintext secret values out of Git</h3>
<p>Git holds everything except secrets. Commit a plaintext secret and you should treat it as <p>In this architecture, plaintext secret values do not live in Git: Git stores the
<b>compromised from that moment</b>: deleting it later does not guarantee it is gone from ExternalSecret reference, and the vault stores the value. Commit a plaintext secret and you
history, forks, clones, CI caches or backups. So the pattern splits the reference from the value: git carries an ExternalSecret should treat it as <b>compromised from that moment</b> - deleting it later does not guarantee
naming a <b>logical key</b>; a vault - Azure Key Vault in the worked example - holds the value; it is gone from history, forks, clones, CI caches or backups. So Git carries an ExternalSecret
naming a <b>logical key</b>; a vault (Azure Key Vault in the worked example) holds the value;
an operator inside the cluster exchanges one for the other at runtime. Rotation happens in the an operator inside the cluster exchanges one for the other at runtime. Rotation happens in the
vault, never as a commit. That is why the vault floats apart in the scene above: it is never vault, not as a commit - which is why the vault sits apart from the pipeline in the scene
absorbed into the pipeline.</p> above.</p>
<p class="sayit">Git holds the shape of the secret. The vault holds the secret.</p> <p class="sayit">Git holds the shape of the secret. The vault holds the secret.</p>
<p class="closeline">Delivery stops being an event and becomes a property: the cluster is always <p class="closeline">Delivery becomes a property rather than an event: the cluster converges on
converging on what the repository says. "Who deployed this?" becomes "who merged this?" - and what the repository says. With protected branches and attributable identities, changes should
that question always has an answer.</p> be traceable to reviewed commits.</p>
<p class="reads"><b>Pre-reads:</b> C-II &middot; <p class="reads"><b>Pre-reads:</b> C-II &middot;
<a href="https://kubernetes.io/docs/concepts/">Kubernetes concepts</a> &middot; <a href="https://kubernetes.io/docs/concepts/">Kubernetes concepts</a> &middot;
<a href="https://docs.github.com/en/get-started">git + pull requests</a> <a href="https://docs.github.com/en/get-started">git + pull requests</a>
@@ -497,13 +507,12 @@
<p class="eyebrow">Course VII &middot; The image supply chain</p> <p class="eyebrow">Course VII &middot; The image supply chain</p>
<h2 id="c7h" class="thesis">A tag is a promise.<br><em>A digest is a fact.</em></h2> <h2 id="c7h" class="thesis">A tag is a promise.<br><em>A digest is a fact.</em></h2>
<p class="lede"><span class="mono">myapp:latest</span> feels like a name. It is a sticky note - a <p class="lede"><span class="mono">myapp:latest</span> feels like a name. It is a sticky note - a
mutable pointer anyone with push rights can peel off one image and press onto another, and mutable reference anyone with push rights can move from one image to another. Registries may
nothing anywhere records that it moved. The digest - sha256 of the image <i>manifest</i>, the audit tag updates, but the tag itself carries no immutability guarantee. The digest - the
small JSON index listing an image's layers, and no relation to the YAML manifests you apply sha256 of the image <i>manifest</i>, the small JSON index listing an image's layers, and no
to a cluster - is relation to the YAML manifests you apply to a cluster - identifies the exact uploaded content.
its actual name: same bytes, same digest, forever. (Careful: the sha256 a build prints is the A local image ID printed by a build is not necessarily the same value, so pin the registry
local image ID, a different hash from the manifest digest the registry mints on push - the digest that consumers actually pull.</p>
pushed one is what you pin.)</p>
</header> </header>
<div class="pin"> <div class="pin">
<div class="viewport"> <div class="viewport">
@@ -521,7 +530,7 @@
<p>Three ways to name an image, in rising order of honesty: <span class="mono">:latest</span> <p>Three ways to name an image, in rising order of honesty: <span class="mono">:latest</span>
(a moving target), <span class="mono">:1.4.2</span> (a label someone maintains, until they (a moving target), <span class="mono">:1.4.2</span> (a label someone maintains, until they
re-push it), and <span class="mono">name:1.4.2@sha256:...</span> - a fact. The tag stays for re-push it), and <span class="mono">name:1.4.2@sha256:...</span> - a fact. The tag stays for
human eyes; the digest does the pulling. Pin by digest and "what is running?" has exactly one human eyes; the digest does the pulling. Pin by digest and "what is running?" has a single
answer.</p> answer.</p>
<p class="fieldnote"><b>Field note.</b> <span class="mono">:latest</span> is how two nodes run <p class="fieldnote"><b>Field note.</b> <span class="mono">:latest</span> is how two nodes run
different code from one manifest - the second node pulled an hour later, after a re-push. different code from one manifest - the second node pulled an hour later, after a re-push.
@@ -529,33 +538,34 @@
<h3>Why a fleet pulls once</h3> <h3>Why a fleet pulls once</h3>
<p>Between the build and the node sits the registry chain. Upstream, a managed registry - Azure <p>Between the build and the node sits the registry chain. Upstream, a managed registry - Azure
Container Registry in the worked example - holds what CI built. In front of the cluster sits a Container Registry in the worked example - holds what CI built. In front of the cluster sits a
<b>mirror</b>: a pull-through cache like zot. The fleet asks the mirror, the mirror asks <b>mirror</b>: a pull-through cache like zot. On a cache miss the mirror fetches the artefact
upstream once, everything after is local. Rate limits, egress cost, disconnected sites, from upstream; subsequent requests can be served locally while that content stays cached. Rate
limits, egress cost, disconnected sites,
control - one place to gate and audit what enters. OpenShift formalises the re-route with control - one place to gate and audit what enters. OpenShift formalises the re-route with
image mirror rules, and carries a sharp edge: digest-mirror rules rewrite <b>digest pulls image mirror rules, and carries a sharp edge: digest-mirror rules rewrite <b>digest pulls
only</b>, so a by-tag pull silently skips them - unless you also add an only</b>, so a by-tag pull silently skips them - unless you also add an
<b>ImageTagMirrorSet</b>, which is the rule type built for tag pulls. Pinning by digest is <b>ImageTagMirrorSet</b>, which is the rule type built for tag pulls. Pinning by digest is
still the habit that makes the digest rules catch everything.</p> still the habit that makes the digest rules catch everything.</p>
<p>At real fleet scale the mirror itself tiers: a <b>central mirror in the cloud</b> fronts <p>At real fleet scale the mirror itself tiers: a <b>central mirror in the cloud</b> fronts
upstream once, and every site's mirror pulls from the centre rather than from upstream upstream, and each site's mirror pulls from the centre rather than from upstream directly. A
directly. A new image ripples outward in layers - upstream to the centre, centre to each new image ripples outward in layers - upstream to the centre, centre to each site as it asks,
site as it asks, site to its nodes over the LAN - instead of every site hammering upstream site to its nodes over the LAN - rather than every site hitting upstream at the same moment.
at the same moment. Upstream sees one consumer; each site sees one hop; the nodes never With a site configured to consume only its local mirror, its node image traffic stays on the
leave the building.</p> local network.</p>
<p class="sayit">Mirrors tier: the load fans out in layers, never all at once.</p> <p class="sayit">Tiered mirrors fan the load out in layers rather than all at once.</p>
<h3>Build once, promote by copy</h3> <h3>Build once, promote by copy</h3>
<p>Every rebuild is a <i>new</i> artefact - in practice a different digest (reproducible builds <p>Every rebuild is a <i>new</i> artefact - in practice a different digest (reproducible builds
are the deliberate exception), untested by the stages before it. are the deliberate exception), untested by the stages before it.
So build <b>once</b>, then promote the same digest through environments by copying, registry So build <b>once</b>, then promote the same digest through environments by copying, registry to
to registry - dev proves the exact bytes prod will run. On a multi-arch image that copy registry - dev proves the exact bytes prod will run. For a multi-architecture image,
(<span class="mono">skopeo copy</span>) needs <span class="mono">--all</span> (and <span class="mono">--preserve-digests</span> to fail <span class="mono">skopeo copy --all</span> copies the complete image list; add
loudly rather than quietly), or you copy one architecture and the digest you promoted is not <span class="mono">--preserve-digests</span> when promotion requires the destination to keep
the digest that lands. Human tags ride along; the digest is the source digests, so the copy fails if that cannot be done rather than silently landing a
the through-line.</p> different digest. Human tags ride along; the digest is the through-line.</p>
<p class="sayit">If the digest changed, it is not a promotion - it is a new candidate.</p> <p class="sayit">If the digest changed, it is not a promotion - it is a new candidate.</p>
<p class="closeline">Names that can move are convenient exactly until they move. Address content <p class="closeline">Names that can move are convenient right up until they move. Address
by what it is, and the supply chain stops resting on trust: it rests on a hash anyone can content by its digest and the supply chain rests on a hash anyone can check, not on trust that
check.</p> a tag stayed put.</p>
<p class="reads"><b>Pre-reads:</b> C-I &middot; <p class="reads"><b>Pre-reads:</b> C-I &middot;
<a href="https://kubernetes.io/docs/concepts/containers/images/">Kubernetes images</a> <a href="https://kubernetes.io/docs/concepts/containers/images/">Kubernetes images</a>
&ensp;<b>Further:</b> <a href="https://zotregistry.dev/">zot</a> &middot; &ensp;<b>Further:</b> <a href="https://zotregistry.dev/">zot</a> &middot;
@@ -589,27 +599,29 @@
<div class="prose"> <div class="prose">
<h3>You are writing a program, not YAML</h3> <h3>You are writing a program, not YAML</h3>
<p>Helm templates are Go text/template: <span class="mono">{{ .Values.device.address }}</span> <p>Helm templates are Go text/template: <span class="mono">{{ .Values.device.address }}</span>
is a pipeline walking a values object, <span class="mono">_helpers.tpl</span> holds the is a pipeline walking a values object. <span class="mono">_helpers.tpl</span> commonly holds
<b>named templates</b> - the partials every manifest <span class="mono">include</span>s, not <b>named templates</b> - reusable partials that manifests can <span class="mono">include</span>
functions you can call bare. You are not writing YAML - you are writing a program whose - rather than ordinary functions invoked directly. You are not writing YAML; you are writing a
output is YAML. So render locally, read the output, and lint what came out, not what went in.</p> program whose output is YAML, so render locally, read the output, and lint what came out rather
than what went in.</p>
<p class="sayit">Review the render, not just the template.</p> <p class="sayit">Review the render, not just the template.</p>
<p class="fieldnote"><b>Field note.</b> ArgoCD renders charts with <p class="fieldnote"><b>Field note.</b> Argo CD renders charts with
<span class="mono">helm template</span> rather than running <span class="mono">helm <span class="mono">helm template</span> rather than running <span class="mono">helm
install</span>, so the lifecycle differs from Helm's own. <span class="mono">lookup</span> install</span>, so the lifecycle differs from Helm's own. <span class="mono">lookup</span>
comes back empty - there is no live cluster at render time. Hooks are not dead, though: Argo comes back empty - there is no live cluster at render time. Hooks are not dead, though: Argo
maps Helm hooks onto its sync phases (pre-install and pre-upgrade become PreSync, post-install maps Helm hooks onto its sync phases (pre-install and pre-upgrade become PreSync, post-install
and post-upgrade become PostSync), while a few - rollback and test hooks - have no equivalent and post-upgrade become PostSync), while a few - rollback and test hooks - have no equivalent
at all. Render the way your deployer renders, and check where your hooks actually land.</p> at all. Render the way your deployer renders, and check where your hooks actually land.</p>
<h3>Contexts: the cluster's whole voice is one small file</h3> <h3>Contexts: one small file per cluster</h3>
<p>The chart owns everything structural - resources, probes, security, policy. Each cluster owns <p>The chart owns everything structural - resources, probes, security, policy. Each cluster owns
one values file: names, addresses, sizes, flags. The context is deliberately values-only; the one values file: names, addresses, sizes, flags. The context is deliberately values-only; the
moment it carries its own manifests there are two owners for one object, and they will moment it carries its own manifests there are two owners for one object, and they will
disagree. One value can feed many rendered artefacts - an address appearing in the app config, disagree. One value can feed several rendered artefacts: the app config, network
the network attachment and two policies renders from one field, so the copies cannot diverge - the lived version is <a href="https://www.bztmon.com/blog/one-value-many-enforcers/">on the blog</a>.</p> attachment and two policies can all render from the same field, so those copies cannot diverge
- the lived version is <a href="https://www.bztmon.com/blog/one-value-many-enforcers/">on the blog</a>.</p>
<p class="sayit">The chart owns the shape. The context owns the numbers.</p> <p class="sayit">The chart owns the shape. The context owns the numbers.</p>
<h3>Make the template refuse</h3> <h3>Make the template refuse</h3>
<p>A template that renders whatever it is given just moves the failure downstream. The grown-up <p>A template that renders whatever it is given just moves the failure downstream. A production-grade
chart carries a <span class="mono">values.schema.json</span>: a context missing a required chart carries a <span class="mono">values.schema.json</span>: a context missing a required
value fails at render time, in the pipeline, with a message naming the field - not months value fails at render time, in the pipeline, with a message naming the field - not months
later as enforcement pointed at nothing.</p> later as enforcement pointed at nothing.</p>
@@ -633,7 +645,7 @@
<h2 id="apxh" class="thesis">Every toolchain stands on<br><em>services it does not run.</em></h2> <h2 id="apxh" class="thesis">Every toolchain stands on<br><em>services it does not run.</em></h2>
<p class="lede">The arc reads like a closed machine: repo to reconciler to registry to node. It <p class="lede">The arc reads like a closed machine: repo to reconciler to registry to node. It
is not closed. Three load-bearing pieces live outside the cluster - and the honest move is to is not closed. Three load-bearing pieces live outside the cluster - and the honest move is to
write down what leans on them, and what actually happens when they are down.</p> write down what leans on them, and what happens when they are down.</p>
</header> </header>
<figure class="still"> <figure class="still">
<img src="__HERO_APPENDIX__" <img src="__HERO_APPENDIX__"
@@ -648,10 +660,10 @@
through a read-only deploy key.</p> through a read-only deploy key.</p>
<p class="leans"><b>Leans on it:</b> sync, rollback, change review, the "who merged this" <p class="leans"><b>Leans on it:</b> sync, rollback, change review, the "who merged this"
answer.</p> answer.</p>
<p class="down"><b>When it is down:</b> Kubernetes keeps running the last applied state <p class="down"><b>When it is down:</b> existing workloads keep running. Repository-backed
indefinitely. The reconciler keeps self-healing only while its rendered manifests are refreshes and new changes become unavailable; what the controller can still evaluate depends
still cached - hours, not forever, and gone after a restart. What stops is <i>change</i>. GitOps degrades to on state already available to it locally. Do not design around an assumed cache lifetime.
read-only, which is the graceful half of the design.</p> What stops is <i>change</i>; the running system is unaffected.</p>
</article> </article>
<article class="card"> <article class="card">
<h3>Azure Key Vault</h3> <h3>Azure Key Vault</h3>
@@ -659,9 +671,9 @@
the value, an operator keeps them synced.</p> the value, an operator keeps them synced.</p>
<p class="leans"><b>Leans on it:</b> secret sync, rotation, the first deploy of anything that <p class="leans"><b>Leans on it:</b> secret sync, rotation, the first deploy of anything that
needs a credential.</p> needs a credential.</p>
<p class="down"><b>When it is down:</b> already-synced Secrets keep working - values are <p class="down"><b>When it is down:</b> already-materialised Kubernetes Secrets typically
materialised in-cluster. What stops is <i>rotation</i> and new secrets. Survivable - unless remain usable while the vault is unavailable. What fails is refresh, rotation and the
you are inside a rotation window.</p> creation of new secret material - survivable, unless you are inside a rotation window.</p>
</article> </article>
<article class="card"> <article class="card">
<h3>zot</h3> <h3>zot</h3>
@@ -672,15 +684,16 @@
scale-up, recovery.</p> scale-up, recovery.</p>
<p class="down"><b>When it is down:</b> the sharpest edge. Upstream down + mirror up = nobody <p class="down"><b>When it is down:</b> the sharpest edge. Upstream down + mirror up = nobody
notices, <i>provided the image is already cached</i> - a cold entry still needs upstream. notices, <i>provided the image is already cached</i> - a cold entry still needs upstream.
Mirror down on a mirror-only pull path = nothing new schedules unless the node already Mirror down on a mirror-only pull path: a pod whose image is already in the node's store
holds the image, and <span class="mono">imagePullPolicy: Always</span> turns a mirror can still start under <span class="mono">IfNotPresent</span>, but
outage into a hard stop.</p> <span class="mono">imagePullPolicy: Always</span> must resolve the reference against the
registry on each launch, so a mirror outage stops it even when the layers are present.</p>
</article> </article>
</div> </div>
<div class="prose"> <div class="prose">
<p class="closeline">None of these outages stop what is already running - they stop change, <p class="closeline">None of these outages stop what is already running - they stop change,
rotation and recovery, in that order of pain. Cache what you pull, split references from rotation and recovery. Cache what you pull, keep secret values in a vault rather than Git, and
values, and let the cluster hold its last known truth without asking anyone's permission.</p> the cluster can hold its last applied state while a dependency is away.</p>
<p class="reads"><b>Further:</b> <a href="https://docs.github.com/">GitHub docs</a> &middot; <p class="reads"><b>Further:</b> <a href="https://docs.github.com/">GitHub docs</a> &middot;
<a href="https://learn.microsoft.com/azure/key-vault/">Azure Key Vault</a> &middot; <a href="https://learn.microsoft.com/azure/key-vault/">Azure Key Vault</a> &middot;
<a href="https://zotregistry.dev/">zot</a> &middot; <a href="https://zotregistry.dev/">zot</a> &middot;
@@ -700,13 +713,13 @@
</header> </header>
<figure class="blueprint"> <figure class="blueprint">
<img src="__DIAG_FLOW__" <img src="__DIAG_FLOW__"
alt="End-to-end delivery flow. Change lane: a commit lands in the GitHub repository, ArgoCD alt="End-to-end delivery flow. Change lane: a commit lands in the GitHub repository, Argo CD
renders and diffs it, and syncs the cluster - the cluster pulls, nothing pushes. renders and diffs it, and syncs the cluster - the cluster pulls rather than being pushed to.
Shape lane: the Helm chart plus a per-cluster values context passes the schema gate Shape lane: the Helm chart plus a per-cluster values context passes the schema gate
and renders the manifests ArgoCD applies. Artefact lane: CI builds once, pushes to and renders the manifests Argo CD applies. Artefact lane: CI builds once, pushes to
Azure Container Registry, the zot mirror caches it, and the node pulls by digest. Azure Container Registry, the zot mirror caches it, and the node pulls by digest.
Secret lane: Azure Key Vault holds the values, the External Secrets operator syncs Secret lane: Azure Key Vault holds the values, the External Secrets operator syncs
them in - git only ever holds the reference. All four lanes converge on the running them in - Git holds only the reference. All four lanes converge on the running
workload."> workload.">
</figure> </figure>
</section> </section>
@@ -730,14 +743,14 @@
assigns apps to clusters by label.</span> assigns apps to clusters by label.</span>
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">docs.redhat.com</a></li> <a href="https://docs.redhat.com/en/documentation/openshift_container_platform">docs.redhat.com</a></li>
<li><b>Argo CD</b><span>The GitOps reconciler that watches a repository and keeps the cluster <li><b>Argo CD</b><span>The GitOps reconciler that watches a repository and keeps the cluster
converged on what it finds there - the pull model that means nothing ever pushes to converged on what it finds there - in the pull model, the cluster fetches its own desired state
production.</span> rather than being pushed to.</span>
<a href="https://argo-cd.readthedocs.io/">argo-cd.readthedocs.io</a></li> <a href="https://argo-cd.readthedocs.io/">argo-cd.readthedocs.io</a></li>
<li><b>GitHub Actions</b><span>CI that builds and tests on every push - in this flow it makes <li><b>GitHub Actions</b><span>CI that builds and tests on each push - in this flow it makes
artefacts and proposes changes, and never holds a credential that can touch a cluster.</span> artefacts and proposes changes, and does not hold a credential that can touch a cluster.</span>
<a href="https://docs.github.com/en/actions">docs.github.com/actions</a></li> <a href="https://docs.github.com/en/actions">docs.github.com/actions</a></li>
<li><b>Azure Key Vault</b><span>The managed vault holding secret values, so git carries only <li><b>Azure Key Vault</b><span>The managed vault holding secret values, so git carries only
references and rotation never has to become a commit.</span> references and rotation does not have to become a commit.</span>
<a href="https://learn.microsoft.com/azure/key-vault/">learn.microsoft.com/key-vault</a></li> <a href="https://learn.microsoft.com/azure/key-vault/">learn.microsoft.com/key-vault</a></li>
<li><b>Azure Container Registry</b><span>The managed upstream registry CI pushes to - where <li><b>Azure Container Registry</b><span>The managed upstream registry CI pushes to - where
the digest is minted that everything downstream pins and promotes.</span> the digest is minted that everything downstream pins and promotes.</span>
@@ -761,7 +774,12 @@
body{margin:0;background:var(--void);color:var(--ink);font-family:var(--sans); body{margin:0;background:var(--void);color:var(--ink);font-family:var(--sans);
-webkit-font-smoothing:antialiased;overflow-x:hidden} -webkit-font-smoothing:antialiased;overflow-x:hidden}
a{color:var(--cyan);text-decoration:none;border-bottom:1px solid var(--line)} a{color:var(--cyan);text-decoration:none;border-bottom:1px solid var(--line)}
a:hover,a:focus-visible{border-bottom-color:var(--cyan);outline:none} a:hover,a:focus-visible{border-bottom-color:var(--cyan)}
:focus-visible{outline:2px solid var(--cyan);outline-offset:3px;border-radius:2px}
.skip{position:absolute;left:-999px;top:0;z-index:100;background:var(--void);color:var(--cyan);
font-family:var(--mono);font-size:.8rem;padding:10px 16px;border:1px solid var(--cyan);border-radius:6px}
.skip:focus{left:12px;top:12px}
.purpose h2.kt{margin:0 0 1rem}
b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cyan)} b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cyan)}
.masthead{max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center} .masthead{max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center}
@@ -813,7 +831,7 @@ b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cya
gap:26px;align-items:center} gap:26px;align-items:center}
.scene{position:relative;margin:0;line-height:0} .scene{position:relative;margin:0;line-height:0}
.scene .flat{display:block;width:100%;height:auto} .scene .flat{display:block;width:100%;height:auto}
.slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat;will-change:transform} .slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat}
.legend{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:13px} .legend{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:13px}
.legend li{padding-left:22px;border-left:1px solid var(--line); .legend li{padding-left:22px;border-left:1px solid var(--line);
@@ -873,6 +891,11 @@ body.fx .crt .line.on{opacity:1;transform:none}
.masthead{padding:9vh 22px 6vh} .masthead{padding:9vh 22px 6vh}
} }
body:not(.fx) .slab{display:none} body:not(.fx) .slab{display:none}
@media (prefers-reduced-motion: reduce){
html{scroll-behavior:auto}
*,*::before,*::after{animation-duration:.001ms !important;animation-iteration-count:1 !important;
transition-duration:.001ms !important;scroll-behavior:auto !important}
}
</style> </style>
<script> <script>
@@ -887,19 +910,28 @@ body:not(.fx) .slab{display:none}
const scene = sec.querySelector(".scene"); const scene = sec.querySelector(".scene");
const flat = scene.querySelector(".flat"); const flat = scene.querySelector(".flat");
const legend = sec.querySelector(".legend"); const legend = sec.querySelector(".legend");
// legend text comes from repo JSON; build with DOM nodes + textContent, never innerHTML
if (legend) man.parts.forEach(p => { if (legend) man.parts.forEach(p => {
if (!p.title && !p.blurb) return;
const li = document.createElement("li"); const li = document.createElement("li");
li.innerHTML = '<p class="t">' + p.title + '</p><p class="b">' + p.blurb + '</p>'; const t = document.createElement("p"); t.className = "t"; t.textContent = p.title;
legend.appendChild(li); const b = document.createElement("p"); b.className = "b"; b.textContent = p.blurb;
li.append(t, b); legend.appendChild(li);
}); });
units.push({ sec, man, scene, flat, legend }); units.push({ sec, man, scene, flat, legend });
}); });
if (!window.gsap || !window.ScrollTrigger) return; // no-JS/no-GSAP: flat heroes + full legends // Reduced motion: leave the flat exploded hero in place (it IS the end state), no slabs, no scrub.
if (RM || !window.gsap || !window.ScrollTrigger) return;
document.body.classList.add("fx"); document.body.classList.add("fx");
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
units.forEach(u => { const wc = (u, on) => u.slabs && u.slabs.forEach(el => el.style.willChange = on ? "transform" : "auto");
// Build a scene's slabs lazily, just before it approaches the viewport. The flat image stays the
// fallback until the slabs are appended, so a scene that never initialises still reads correctly.
const initScene = u => {
if (u.slabs) return;
u.slabs = u.man.parts.map(p => { u.slabs = u.man.parts.map(p => {
const d = document.createElement("div"); const d = document.createElement("div");
d.className = "slab"; d.className = "slab";
@@ -909,38 +941,29 @@ body:not(.fx) .slab{display:none}
u.scene.appendChild(d); u.scene.appendChild(d);
return d; return d;
}); });
u.flat.style.visibility = "hidden";
const lis = u.legend ? [...u.legend.children] : []; const lis = u.legend ? [...u.legend.children] : [];
const n = lis.length; const n = lis.length;
u.apply = t => { u.apply = t => {
// stagger > 0 = cascade: parts complete their travel one after another in list order const sg = u.man.stagger || 0, k = u.man.parts.length;
const sg = u.man.stagger || 0;
u.man.parts.forEach((p, i) => { u.man.parts.forEach((p, i) => {
const k = u.man.parts.length;
const pt = sg ? Math.min(1, Math.max(0, t * (1 + sg * (k - 1)) - sg * i)) : t; const pt = sg ? Math.min(1, Math.max(0, t * (1 + sg * (k - 1)) - sg * i)) : t;
// reverse scenes sit assembled at rest and FLY APART as the scrub advances
const e = u.man.reverse ? (1 - pt) : pt; const e = u.man.reverse ? (1 - pt) : pt;
u.slabs[i].style.transform = u.slabs[i].style.transform =
"translate3d(" + (p.dx * (1 - e)).toFixed(3) + "%," + (p.dy * (1 - e)).toFixed(3) + "%,0)"; "translate3d(" + (p.dx * (1 - e)).toFixed(3) + "%," + (p.dy * (1 - e)).toFixed(3) + "%,0)";
}); });
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")"; u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
// the side list lights in reading order, top to bottom, as the scrub advances if (n) lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
}; };
}); u.flat.style.visibility = "hidden";
const boot = () => { const desktop = document.documentElement.scrollHeight - innerHeight > 200 &&
const scrollable = document.documentElement.scrollHeight - innerHeight > 200; matchMedia("(min-width: 901px)").matches;
units.forEach(u => { if (desktop) {
if (RM) { u.mode = "reduced-motion"; u.apply(1); return; }
if (scrollable && matchMedia("(min-width: 901px)").matches) {
u.mode = "pin+scrub"; u.mode = "pin+scrub";
ScrollTrigger.create({ ScrollTrigger.create({
trigger: u.sec.querySelector(".pin"), trigger: u.sec.querySelector(".pin"),
start: "top top", start: "top top", end: "+=1800", pin: true, scrub: 0.6,
end: "+=1800", onToggle: self => wc(u, self.isActive),
pin: true,
scrub: 0.6,
onUpdate: self => u.apply(self.progress), onUpdate: self => u.apply(self.progress),
onRefreshInit: () => u.apply(0) onRefreshInit: () => u.apply(0)
}); });
@@ -948,22 +971,29 @@ body:not(.fx) .slab{display:none}
} else { } else {
u.mode = "assemble"; u.mode = "assemble";
u.sec.querySelector(".pin").style.minHeight = "0"; u.sec.querySelector(".pin").style.minHeight = "0";
u.apply(0); u.apply(0); wc(u, true);
const io = new IntersectionObserver(es => {
if (!es.some(e => e.isIntersecting)) return;
io.disconnect();
gsap.to({ v: 0 }, { v: 1, duration: 1.6, ease: "power3.out", gsap.to({ v: 0 }, { v: 1, duration: 1.6, ease: "power3.out",
onUpdate: function () { u.apply(this.targets()[0].v); } }); onUpdate: function () { u.apply(this.targets()[0].v); },
}, { threshold: 0.2 }); onComplete: () => wc(u, false) });
io.observe(u.scene);
} }
ScrollTrigger.refresh();
};
const boot = () => {
// initialise each scene shortly before it reaches the viewport; keep the flat image until then
const near = new IntersectionObserver((es, obs) => {
es.forEach(e => {
if (!e.isIntersecting) return;
const u = units.find(x => x.sec === e.target);
if (u) initScene(u);
obs.unobserve(e.target);
}); });
if (!RM) ScrollTrigger.refresh(); }, { rootMargin: "400px 0px 400px 0px" });
units.forEach(u => near.observe(u.sec));
}; };
// the aperitif terminal types its transcript once, when it first enters view // the aperitif terminal types its transcript once, when it first enters view
document.querySelectorAll(".crt").forEach(crt => { document.querySelectorAll(".crt").forEach(crt => {
const lines = [...crt.querySelectorAll(".line")]; const lines = [...crt.querySelectorAll(".line")];
if (RM) { lines.forEach(l => l.classList.add("on")); return; }
const io = new IntersectionObserver(es => { const io = new IntersectionObserver(es => {
if (!es.some(x => x.isIntersecting)) return; if (!es.some(x => x.isIntersecting)) return;
io.disconnect(); io.disconnect();
+102 -12
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
// Site build for the delivery-arc page (CSP-clean: script-src 'self', no inline JS). // Site build for the delivery-arc page (CSP-clean: script-src 'self', no inline JS).
// Reads pilot/arc.tpl.html, splits the app <script> out to arc.js, injects the scene // Reads pilot/arc.tpl.html, splits the app <script> out to a fingerprinted arc.js, injects
// manifests, points heroes at /assets/ files, and wraps a full HTML document. // the scene manifests, fingerprints every /assets/ file, sets intrinsic image dimensions,
// wraps a full HTML document, writes build.json, and runs preflight gates.
// Runs on bare node (the build image has no python). Artifact build = assemble-arc.py. // Runs on bare node (the build image has no python). Artifact build = assemble-arc.py.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -10,9 +11,35 @@ import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const DIST = process.argv[2] || join(ROOT, "dist"); const DIST = process.argv[2] || join(ROOT, "dist");
const SITE = "https://learn.bztmon.com";
const BUILD_REV = process.env.BUILD_REV || "dev";
const man = t => JSON.stringify(JSON.parse(readFileSync(join(ROOT, "pilot", `manifest-${t}.json`), "utf8"))); const man = t => JSON.stringify(JSON.parse(readFileSync(join(ROOT, "pilot", `manifest-${t}.json`), "utf8")));
// --- intrinsic WebP dimensions (VP8 / VP8L / VP8X), so the browser reserves layout space ---
function webpSize(buf) {
if (buf.length < 30 || buf.toString("ascii", 0, 4) !== "RIFF") return null;
const fourcc = buf.toString("ascii", 12, 16);
try {
if (fourcc === "VP8 ") {
return { w: (buf.readUInt16LE(26) & 0x3fff), h: (buf.readUInt16LE(28) & 0x3fff) };
}
if (fourcc === "VP8L") {
const b = buf; const o = 21;
const w = 1 + (((b[o + 1] & 0x3f) << 8) | b[o]);
const h = 1 + (((b[o + 3] & 0x0f) << 10) | (b[o + 2] << 2) | ((b[o + 1] & 0xc0) >> 6));
return { w, h };
}
if (fourcc === "VP8X") {
const o = 24;
const w = 1 + (buf[o] | (buf[o + 1] << 8) | (buf[o + 2] << 16));
const h = 1 + (buf[o + 3] | (buf[o + 4] << 8) | (buf[o + 5] << 16));
return { w, h };
}
} catch { /* fall through */ }
return null;
}
let tpl = readFileSync(join(ROOT, "pilot/arc.tpl.html"), "utf8"); let tpl = readFileSync(join(ROOT, "pilot/arc.tpl.html"), "utf8");
// split the app script (the last <script> block) out of the template // split the app script (the last <script> block) out of the template
@@ -53,36 +80,65 @@ const emit = (srcPath, base, ext) => {
const buf = readFileSync(srcPath); const buf = readFileSync(srcPath);
const name = `${base}.${fp(buf)}.${ext}`; const name = `${base}.${fp(buf)}.${ext}`;
writeFileSync(join(DIST, "assets", name), buf); writeFileSync(join(DIST, "assets", name), buf);
return "/assets/" + name; return { url: "/assets/" + name, buf };
}; };
const heroUrls = {};
for (const [ph, file] of Object.entries(heroes)) { for (const [ph, file] of Object.entries(heroes)) {
const base = file.replace(/\.webp$/, ""); const base = file.replace(/\.webp$/, "");
body = body.replace(ph, emit(join(ROOT, "assets/dist", file), base, "webp")); const { url, buf } = emit(join(ROOT, "assets/dist", file), base, "webp");
heroUrls[ph] = url;
const dims = webpSize(buf);
const dimAttr = dims ? ` width="${dims.w}" height="${dims.h}"` : "";
// Course 00's hero is the first visible image -> load eager; the rest are below the fold.
const load = ph === "__HERO_SURROUND__" ? "eager" : "lazy";
body = body.replace(`src="${ph}"`, `src="${url}"${dimAttr} decoding="async" loading="${load}"`);
}
{
const { url } = emit(join(ROOT, "assets/dist/delivery-flow.svg"), "delivery-flow", "svg");
body = body.replace(`src="__DIAG_FLOW__"`,
`src="${url}" width="1180" height="660" decoding="async" loading="lazy"`);
} }
body = body.replace("__DIAG_FLOW__", emit(join(ROOT, "assets/dist/delivery-flow.svg"), "delivery-flow", "svg"));
if (body.includes("__HERO_") || body.includes("__DIAG_") || app.includes("__MAN_")) throw new Error("unfilled placeholder"); if (body.includes("__HERO_") || body.includes("__DIAG_") || app.includes("__MAN_")) throw new Error("unfilled placeholder");
const favicon = "data:image/svg+xml," + encodeURIComponent( const favicon = "data:image/svg+xml," + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#070b14"/><rect x="6" y="4" width="14" height="7" rx="1.5" fill="#3fbaf5"/><rect x="11" y="13" width="14" height="7" rx="1.5" fill="#7dd0f9"/><rect x="6" y="22" width="14" height="7" rx="1.5" fill="#e879f9"/></svg>`); `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#070b14"/><rect x="6" y="4" width="14" height="7" rx="1.5" fill="#3fbaf5"/><rect x="11" y="13" width="14" height="7" rx="1.5" fill="#7dd0f9"/><rect x="6" y="22" width="14" height="7" rx="1.5" fill="#e879f9"/></svg>`);
const gsapRef = emit(join(ROOT, "gsap.min.js"), "gsap.min", "js"); const gsapRef = emit(join(ROOT, "gsap.min.js"), "gsap.min", "js").url;
const stRef = emit(join(ROOT, "st.min.js"), "st.min", "js"); const stRef = emit(join(ROOT, "st.min.js"), "st.min", "js").url;
const appName = `arc.${fp(app)}.js`; const appName = `arc.${fp(app)}.js`;
writeFileSync(join(DIST, "assets", appName), app); writeFileSync(join(DIST, "assets", appName), app);
const appRef = "/assets/" + appName; const appRef = "/assets/" + appName;
const DESC = "Interactive teardown of Kubernetes and OpenShift delivery: containers, Services, GitOps, image supply chains and Helm, explained with scroll-driven exploded diagrams.";
const OG_IMG = SITE + heroUrls["__HERO_GITOPS__"];
const html = `<!doctype html> const html = `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Exploded Cluster</title> <title>The Exploded Cluster</title>
<meta name="description" content="Interactive teardown of the toolchain around Kubernetes and OpenShift: GitOps, the image supply chain, and Helm - every scene one generated hero, sliced and scrubbed."> <meta name="description" content="${DESC}">
<meta name="theme-color" content="#070b14"> <meta name="theme-color" content="#070b14">
<meta name="build-revision" content="${BUILD_REV}">
<link rel="canonical" href="${SITE}/">
<meta property="og:title" content="The Exploded Cluster">
<meta property="og:description" content="${DESC}">
<meta property="og:type" content="website">
<meta property="og:url" content="${SITE}/">
<meta property="og:image" content="${OG_IMG}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="The Exploded Cluster">
<meta name="twitter:description" content="${DESC}">
<meta name="twitter:image" content="${OG_IMG}">
<link rel="icon" href="${favicon}"> <link rel="icon" href="${favicon}">
</head> </head>
<body> <body>
<a class="skip" href="#main-content">Skip to content</a>
<main id="main-content">
${body} ${body}
</main>
<script src="${gsapRef}"></script> <script src="${gsapRef}"></script>
<script src="${stRef}"></script> <script src="${stRef}"></script>
<script src="${appRef}"></script> <script src="${appRef}"></script>
@@ -91,8 +147,42 @@ ${body}
`; `;
writeFileSync(join(DIST, "index.html"), html); writeFileSync(join(DIST, "index.html"), html);
writeFileSync(join(DIST, "build.json"), JSON.stringify({ revision: BUILD_REV, app: appName }) + "\n");
// CSP gate: no inline <script> may survive into the served HTML // ---------------------------------------------------------------------------
const inline = (html.match(/<script(?![^>]*src=)[^>]*>/gi) || []).length; // Preflight gates: fail the build loudly rather than ship a known defect.
if (inline) throw new Error("inline <script> in index.html - CSP would block it"); // ---------------------------------------------------------------------------
console.log(`site build -> ${DIST}: index.html ${(html.length / 1024).toFixed(0)}KB, all assets fingerprinted (${appName})`); const fail = [];
const must = (cond, msg) => { if (!cond) fail.push(msg); };
// no inline <script> may survive (CSP script-src 'self')
must(!(html.match(/<script(?![^>]*src=)[^>]*>/gi) || []).length, "inline <script> present");
// no unresolved template placeholders
must(!/__[A-Z_]+__/.test(html), "unresolved __PLACEHOLDER__ in output");
// exactly one <h1>
must((html.match(/<h1[\s>]/gi) || []).length === 1, "expected exactly one <h1>");
// unique ids
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(m => m[1]);
must(new Set(ids).size === ids.length, "duplicate id attribute(s): " +
ids.filter((v, k) => ids.indexOf(v) !== k).join(", "));
// internal anchor targets resolve
const idset = new Set(ids);
for (const m of html.matchAll(/href="#([^"]+)"/g)) {
if (m[1] && !idset.has(m[1])) fail.push("dangling internal link #" + m[1]);
}
// banned / obsolete strings (user-visible copy)
const banned = [
[/ArgoCD/, "user-visible 'ArgoCD' (use 'Argo CD')"],
[/random non-root UID/, "obsolete 'random non-root UID'"],
[/nothing anywhere records that it moved/, "obsolete tag-immutability claim"],
[/no interface owns that address/, "obsolete universal ClusterIP claim"],
[/Three commands/, "'Three commands' contradicts four shown"],
[/selfHeal puts it back; what leaves git leaves the cluster/, "stale unconditional self-heal legend"],
[/every manifest <span class="mono">include<\/span>s/, "broken 'include s' construction"],
[/two policies renders from one field/, "subject/verb error 'renders from one field'"],
[/grown-up chart/, "'grown-up chart' (use 'production-grade chart')"],
];
for (const [re, msg] of banned) if (re.test(html)) fail.push(msg);
if (fail.length) { console.error("PREFLIGHT FAILED:\n - " + fail.join("\n - ")); process.exit(1); }
console.log(`site build -> ${DIST}: index.html ${(html.length / 1024).toFixed(0)}KB, rev ${BUILD_REV}, preflight OK (${appName})`);
+2 -2
View File
@@ -146,7 +146,7 @@
}, },
{ {
"name": "engine", "name": "engine",
"title": "ArgoCD", "title": "Argo CD",
"blurb": "Watches repo + path + revision, compares desired against actual, and syncs the difference.", "blurb": "Watches repo + path + revision, compares desired against actual, and syncs the difference.",
"z": 5, "z": 5,
"points": [ "points": [
@@ -545,7 +545,7 @@
{ {
"name": "shard", "name": "shard",
"title": "Drift", "title": "Drift",
"blurb": "A hand-edited live object. selfHeal puts it back; what leaves git leaves the cluster.", "blurb": "A hand-edited live object becomes drift. With self-heal enabled Argo CD can restore desired state; with prune enabled removed desired objects can be deleted.",
"z": 4, "z": 4,
"points": [ "points": [
[ [
+1 -1
View File
@@ -5,7 +5,7 @@
{ {
"name": "kubelet", "name": "kubelet",
"title": "kubelet", "title": "kubelet",
"blurb": "The node agent. It decides what should be running and tells the runtime over CRI - it does not create containers itself.", "blurb": "The node agent. It ensures the PodSpecs assigned to this node are running and healthy, using CRI to instruct the container runtime.",
"z": 0, "z": 0,
"points": [ "points": [
[ [
+2 -2
View File
@@ -408,8 +408,8 @@
}, },
{ {
"name": "gate", "name": "gate",
"title": "initContainer", "title": "Regular init container",
"blurb": "Runs first, to completion - the gatekeeper underneath everything.", "blurb": "Runs in order and completes before the app containers start. A native sidecar is the restartable exception.",
"z": 0, "z": 0,
"points": [ "points": [
[ [
+2 -2
View File
@@ -217,7 +217,7 @@
{ {
"name": "mirror", "name": "mirror",
"title": "The mirror", "title": "The mirror",
"blurb": "zot, a pull-through cache: the fleet asks it, it asks upstream once, everything after is local.", "blurb": "zot, a pull-through cache: on a cache miss it fetches from upstream, then serves subsequent requests locally while the content stays cached.",
"z": 0, "z": 0,
"points": [ "points": [
[ [
@@ -433,7 +433,7 @@
{ {
"name": "tag", "name": "tag",
"title": "The tag", "title": "The tag",
"blurb": "A promise - a mutable pointer anyone with push rights can move, and nothing records that it moved.", "blurb": "A mutable reference. Registries may audit tag updates, but the tag itself carries no immutability guarantee.",
"z": 0, "z": 0,
"points": [ "points": [
[ [