895 lines
53 KiB
HTML
895 lines
53 KiB
HTML
<header class="masthead">
|
|
<p class="eyebrow">The Exploded Cluster · The Delivery Arc</p>
|
|
<h1>The machine was the easy part.<br><em>Now watch how software reaches it.</em></h1>
|
|
<p class="lede">The foundations first - what an image is, what a cluster is - then the toolchain
|
|
that delivers to them: how change travels, how images are named, and how one definition serves
|
|
a fleet. Scroll, and each machine comes apart.</p>
|
|
<p class="scrollcue" aria-hidden="true">scroll<span></span></p>
|
|
</header>
|
|
|
|
<nav class="toc" aria-label="index">
|
|
<p class="kt">Index</p>
|
|
<ol>
|
|
<li><a href="#c0"><b>Course 00</b><span>Aperitif - three commands, and what actually just happened</span></a></li>
|
|
<li><a href="#c1"><b>Course I</b><span>Docker - 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="#c3a"><b>Course III</b><span>The node - where intent becomes a process</span></a></li>
|
|
<li><a href="#c3b"><b>Course III</b><span>The pod - one IP, shared fate</span></a></li>
|
|
<li><a href="#c4"><b>Course IV</b><span>The traffic - pods are cattle, Services are the brand</span></a></li>
|
|
<li><a href="#c5"><b>Course V</b><span>OpenShift - Kubernetes with opinions, and a security guard</span></a></li>
|
|
<li><a href="#c6"><b>Course VI</b><span>GitOps - nobody deploys anything; the cluster syncs itself</span></a></li>
|
|
<li><a href="#c7"><b>Course VII</b><span>The image supply chain - a tag is a promise, a digest is a fact</span></a></li>
|
|
<li><a href="#c8"><b>Course VIII</b><span>Helm - a chart is a function; the values file is the cluster speaking</span></a></li>
|
|
<li><a href="#apx"><b>Appendix</b><span>The dependency ledger - GitHub, Azure Key Vault, zot</span></a></li>
|
|
<li><a href="#flow"><b>Blueprint</b><span>The delivery flow, end to end</span></a></li>
|
|
<li><a href="#library"><b>Library</b><span>Official documentation for every technology on this page</span></a></li>
|
|
</ol>
|
|
</nav>
|
|
|
|
<section class="purpose" aria-labelledby="purph">
|
|
<p class="kt" id="purph">Purpose of this document</p>
|
|
<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
|
|
real components and wired to your scroll, so the architecture moves while the words explain it.
|
|
Start with the aperitif's three terminal commands; finish knowing how a change travels from a
|
|
git commit to a running, secret-fed, digest-pinned workload on a fleet. Every claim comes from
|
|
running these systems for real, and the library at the end links only to official
|
|
documentation.</p>
|
|
</section>
|
|
|
|
|
|
<!-- ============ COURSE 00 ============ -->
|
|
<section class="course" id="c0" data-scene="surround" aria-labelledby="c0h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course 00 · Aperitif</p>
|
|
<h2 id="c0h" class="thesis">Three commands.<br><em>What actually just happened?</em></h2>
|
|
<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>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport solo">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_SURROUND__"
|
|
alt="Six dark armour fragments with neon seams framing a large empty centre - the
|
|
casing of a machine caught the instant before it comes apart.">
|
|
<div class="crt" role="img" aria-label="A terminal transcript: docker build, docker push,
|
|
kubectl apply, kubectl get pods showing Running - then the question: but what
|
|
actually just happened?">
|
|
<p class="line">$ docker build -t shop/api:1.4.2 .</p>
|
|
<p class="line ok">=> exported image sha256:9f2c41...</p>
|
|
<p class="line">$ docker push shop/api:1.4.2</p>
|
|
<p class="line">$ kubectl apply -f deploy.yaml</p>
|
|
<p class="line ok">deployment.apps/api created</p>
|
|
<p class="line">$ kubectl get pods</p>
|
|
<p class="line ok">api-7d4b9c6-x2k4f 1/1 Running</p>
|
|
<p class="line q">...but what actually just happened?</p>
|
|
</div>
|
|
</figure>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<p>An image got <b>built</b> - of what, exactly? <b>Pushed</b> - to where, and what travelled?
|
|
<b>Applied</b> - which is not the same as launched. <b>Running</b> - according to whom?
|
|
Every course below takes one of those words apart. The armour is already loose.</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE I ============ -->
|
|
<section class="course" id="c1" data-scene="docker" aria-labelledby="c1h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course I · Docker</p>
|
|
<h2 id="c1h" class="thesis">An image is not a box.<br><em>It is a stack of frozen diffs.</em></h2>
|
|
<p class="lede">Scroll, and the thing you keep calling "a container image" comes apart in your
|
|
hands. Four layers. Each one only stores what changed from the layer under it - and here they
|
|
rise one at a time, bottom up.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_DOCKER__"
|
|
alt="An exploded isometric view of a container image: four stacked slabs floating
|
|
apart - a heavy metal base, a circuit-etched dependency layer, a magenta-traced
|
|
code layer, and a thin frosted-glass writable layer on top.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>A stack of diffs wearing a name</h3>
|
|
<p>An image is not a copy of a machine. It is a stack of read-only layers, each recording only
|
|
what changed from the one beneath. The base OS is pulled once and shared by every image on
|
|
the host; your dependencies sit on it; your code - usually the smallest layer, always the
|
|
most volatile - sits above that; and the writable layer is created fresh per container and
|
|
thrown away when it dies. Nothing you write there survives.</p>
|
|
<p class="sayit">Change a layer and every layer above it must be rebuilt.</p>
|
|
<h3>The order is a caching decision</h3>
|
|
<p>The builder caches layer by layer, and a cached layer survives only while everything
|
|
beneath it is unchanged. Put <span class="mono">COPY . .</span> above your dependency
|
|
install and you have told the builder to throw the dependency cache away every time one
|
|
line of code changes. Dependencies first, code last - a Dockerfile is a cache policy that
|
|
happens to build software.</p>
|
|
<p class="fieldnote"><b>Field note.</b> A rebuild that takes twenty minutes and one that
|
|
takes twenty seconds are usually the same Dockerfile with the lines swapped.</p>
|
|
<p class="closeline">This stack explains the whole ecosystem above it: sharing explains why
|
|
pulls are fast, immutability explains why a digest can name the exact bytes (Course VII),
|
|
and the throwaway top layer explains why state needs volumes. One idea - frozen diffs -
|
|
all the way down.</p>
|
|
<p class="reads"><b>Pre-reads:</b> none - start here
|
|
 <b>Further:</b> <a href="https://docs.docker.com/get-started/">Docker get-started</a> ·
|
|
<a href="https://docs.docker.com/build/">Docker build + layers</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE II ============ -->
|
|
<section class="course" id="c2" data-scene="cluster" aria-labelledby="c2h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course II · Kubernetes</p>
|
|
<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
|
|
want - declarative intent - and the cluster works, forever, to make it true. Scroll, and
|
|
the formation splits: the half that decides rises, the halves that run spread below.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_CLUSTER__"
|
|
alt="A small fleet mid-explosion: one wide command slab with a strong cyan seam
|
|
hovering above a row of three identical worker blocks, all floating apart in
|
|
the void.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>Desired against actual, forever</h3>
|
|
<p>The habit underneath everything: the <b>reconciliation loop</b> - compare desired state
|
|
against actual state, fix the difference, repeat. That <span class="mono">kubectl apply</span>
|
|
didn't launch anything; it filed paperwork. The machine took it from there, and it never
|
|
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>
|
|
<p class="sayit">Kubernetes doesn't run your app - it reconciles it.</p>
|
|
<h3>One half decides, one half runs</h3>
|
|
<p>The split in the scene is the split that makes everything else possible: a <b>control
|
|
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
|
|
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
|
|
are arm-wrestling the reconciler - and it does not get tired. Change the desired state
|
|
instead.</p>
|
|
<p class="closeline">Everything the delivery arc teaches from Course VI onward is this loop
|
|
wearing bigger clothes - git as the desired state, whole fleets as reconciled objects.
|
|
Learn the promise once; it repeats all the way up.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-I
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/overview/">Kubernetes overview</a> ·
|
|
<a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE III - THE NODE ============ -->
|
|
<section class="course" id="c3a" data-scene="node" aria-labelledby="c3ah">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course III · The node</p>
|
|
<h2 id="c3ah" class="thesis">Where intent<br><em>becomes a process.</em></h2>
|
|
<p class="lede">Everything so far was decision. This is the machine where a pod stops being
|
|
paperwork and starts being a process - and the chain assembles link by link as you scroll.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_NODE__"
|
|
alt="A diagonal chain of node machinery: a visor-lit kubelet module, a layered
|
|
container-runtime engine, a ported CNI ring, a magenta routing prism - and a
|
|
small glowing pod capsule descending toward the engine.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>One component starts containers. One.</h3>
|
|
<p>Every node runs a <b>kubelet</b> - and in the entire system it is the only component that
|
|
actually starts your container. It speaks CRI to <b>containerd</b> or CRI-O, which pulls the
|
|
image (through the mirror of Course VII) and launches it. A <b>CNI plugin</b> hands the pod
|
|
a real IP, and <b>kube-proxy</b> - or an eBPF datapath replacing it - makes Service
|
|
addresses route to real pods. The kubelet drives, the runtime executes, the CNI wires, the
|
|
proxy routes.</p>
|
|
<p class="sayit">The kubelet is the only component that actually starts your container.</p>
|
|
<p class="fieldnote"><b>Field note.</b> Node NotReady? Check kubelet-to-API-server
|
|
connectivity before anything else - a node that cannot phone home is presumed lost.</p>
|
|
<p class="closeline">The control plane never touches your workload. It writes intent; the
|
|
kubelet reads it and makes it flesh. Authority and execution meet exactly here, nowhere
|
|
else.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/architecture/">cluster architecture</a> ·
|
|
<a href="https://kubernetes.io/docs/concepts/architecture/#node-components">node components</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE III - THE POD ============ -->
|
|
<section class="course" id="c3b" data-scene="pod" aria-labelledby="c3bh">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course III · The pod</p>
|
|
<h2 id="c3bh" class="thesis">One IP,<br><em>shared fate.</em></h2>
|
|
<p class="lede">A pod is not a container - it is a jacket around one or more. Scroll, and the
|
|
capsule opens like a clamshell: shells apart, contents on display.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_POD__"
|
|
alt="A pod capsule blown open: two frosted shell halves floating apart, a cyan app
|
|
container column standing on an amber-lit init gate, a smaller magenta sidecar
|
|
beside it, and a stack of translucent volume discs.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The jacket, not the container</h3>
|
|
<p>Everything inside the jacket shares a <b>network namespace</b>: one IP, localhost between
|
|
friends, volumes mountable by all. <b>initContainers</b> run first, in order, to completion -
|
|
gatekeepers under everything. <b>Sidecars</b> ride along with their own containers and their
|
|
own jobs: proxy, logs, reload.</p>
|
|
<h3>Three probes, three different jobs</h3>
|
|
<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
|
|
impatient liveness probe looks exactly like a crash.</p>
|
|
<p class="sayit">containerPort is documentation - the app still has to bind the port itself.</p>
|
|
<p class="fieldnote"><b>Field note.</b> Exit 137 with reason Error is the kubelet's probe
|
|
kill; OOMKilled is memory. Same exit code, different crimes - read the reason.</p>
|
|
<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
|
|
mysterious.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-III's node scene above
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/workloads/pods/">pods</a> ·
|
|
<a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/">the three probes</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE IV ============ -->
|
|
<section class="course" id="c4" data-scene="traffic" aria-labelledby="c4h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course IV · The traffic</p>
|
|
<h2 id="c4h" class="thesis">Pods are cattle.<br><em>Services are the brand.</em></h2>
|
|
<p class="lede">Pods die, respawn and change addresses - and traffic still arrives. Scroll,
|
|
and the delivery route assembles checkpoint by checkpoint; watch what happens to the pod
|
|
that stops answering.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_TRAFFIC__"
|
|
alt="A delivery route in the void: a glowing client orb, a fanned load-balancer
|
|
wedge, an open ingress doorframe, a Service prism with a bright core, a lit
|
|
ready pod - and a dark unlit pod fallen out of the line.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The stable name in front of the churn</h3>
|
|
<p>A <b>Service</b> is the fixed point: a ClusterIP inside, a LoadBalancer at the edge,
|
|
Ingress or the Gateway API doing host- and path-routing above. Clients hold the name; the
|
|
pods behind it come and go without anyone being told.</p>
|
|
<h3>Readiness decides membership</h3>
|
|
<p>A pod failing its readiness probe <b>silently leaves the pool</b>. No error, no event at
|
|
the client - traffic simply stops arriving. That is the feature: broken instances remove
|
|
themselves. It is also the first place to look when traffic "disappears".</p>
|
|
<p class="sayit">Never talk to a pod; talk to a Service.</p>
|
|
<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>
|
|
<p class="closeline">The dark pod in the scene is not an error state - it is the system
|
|
working. Membership is earned per probe, continuously, forever.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-III's pod scene (readiness lives there)
|
|
 <b>Further:</b> <a href="https://kubernetes.io/docs/concepts/services-networking/">Services and networking</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE V ============ -->
|
|
<section class="course" id="c5" data-scene="orbit" aria-labelledby="c5h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course V · OpenShift</p>
|
|
<h2 id="c5h" class="thesis">Kubernetes with opinions -<br><em>and a security guard.</em></h2>
|
|
<p class="lede">OpenShift is a distribution of Kubernetes: same engine, opinionated chassis.
|
|
Scroll, and the opinions bloom outward from the core they orbit.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_ORBIT__"
|
|
alt="A glowing geodesic core ringed by six satellites: a magenta admission shield,
|
|
an open route arch, interlocking operator rings, a handheld console, an
|
|
amber-lit stack of machine-config plates and a compact single-node box.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The doorman interviews every pod</h3>
|
|
<p>The <b>SCC</b> is admission deciding what a pod may BE. The default, restricted-v2, runs
|
|
your container as a <b>random non-root UID</b> - your image has to cope. Workloads that
|
|
genuinely need privilege get a dedicated ServiceAccount bound to a minimal custom SCC,
|
|
never the stock one.</p>
|
|
<h3>The platform runs itself</h3>
|
|
<p><b>Routes</b> predate Ingress and still rule here. <b>Operators</b> run the platform's own
|
|
machinery. The OS underneath is immutable - changed by <b>MachineConfig</b>, never by SSH.
|
|
And <b>SNO</b> puts the entire promise on one box at the edge. At fleet scale the labels
|
|
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="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
|
|
up.</p>
|
|
<p class="closeline">Everything in the ring is opinion wrapped around the same core you
|
|
already know. Learn the core once; the opinions are just house rules.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II
|
|
 <b>Further:</b> <a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift documentation</a> ·
|
|
<a href="https://www.redhat.com/en/technologies/cloud-computing/openshift">Red Hat OpenShift</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE VI ============ -->
|
|
<section class="course" id="c6" data-scene="gitops" aria-labelledby="c6h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course VI · GitOps</p>
|
|
<h2 id="c6h" class="thesis">Nobody deploys anything.<br><em>The cluster syncs itself.</em></h2>
|
|
<p class="lede">The mental model everyone arrives with: someone with credentials pushes manifests
|
|
at the cluster. In GitOps nothing is pushed. A repository holds the desired state, an agent
|
|
inside the cluster watches it, and the cluster pulls its own future from git. kubectl is for
|
|
looking; git is for changing.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_GITOPS__"
|
|
alt="An exploded chain: an etched repository crystal, a twin-ring reconciler engine,
|
|
a stack of rendered manifests and a cluster slab - with a drift shard falling
|
|
away and an armoured secret vault floating deliberately apart.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>The loop you already know, one level up</h3>
|
|
<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
|
|
revision - watch this branch of this repository - and the controller renders what it finds
|
|
there, compares it against the live cluster, and syncs the difference. The deploy button is a
|
|
git commit; the audit log is git log; code review <i>is</i> change control.</p>
|
|
<p class="sayit">kubectl apply is for archaeology, not for change.</p>
|
|
<h3>Pull, not push - the security inversion</h3>
|
|
<p>The cluster <b>pulls</b>. No CI system, no laptop, no build pipeline holds a credential that
|
|
can touch it - the agent inside holds a read-only deploy key, and the trust arrow points out.
|
|
Compromise the build system and you can propose a change, which is visible; you cannot reach
|
|
into production. Hand-edit a live object and the controller flags it OutOfSync - selfHeal puts
|
|
it back, and what leaves git leaves the cluster. Rollback is <span class="mono">git revert</span>,
|
|
which is why commit hygiene is an operational skill.</p>
|
|
<p class="fieldnote"><b>Field note.</b> Fighting the reconciler over SSH is arm-wrestling a
|
|
machine that does not get tired - C-II's warning, now with a face. The fix is always
|
|
upstream, in git.</p>
|
|
<h3>At fleet scale, the label is the deploy button</h3>
|
|
<p>Run many OpenShift clusters under a hub - RHACM, with edge clusters 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
|
|
labels a cluster wears, and the chosen cluster pulls its assignment. Labelling the cluster
|
|
<i>is</i> the deploy action: attach the label and the app follows, remove it and the app
|
|
leaves. The same pull model as above, one level bigger - a cluster's labels are its
|
|
entitlements, reconciled like everything else.</p>
|
|
<p class="sayit">Label the cluster; the app follows.</p>
|
|
<h3>The one thing git never holds</h3>
|
|
<p>Git holds everything except secrets - a secret in git is published, forever, to everyone who
|
|
ever clones. So the pattern splits the reference from the value: 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
|
|
vault, never as a commit. That is why the vault floats apart in the scene above: it is never
|
|
absorbed into the pipeline.</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
|
|
converging on what the repository says. "Who deployed this?" becomes "who merged this?" - and
|
|
that question always has an answer.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II ·
|
|
<a href="https://kubernetes.io/docs/concepts/">Kubernetes concepts</a> ·
|
|
<a href="https://docs.github.com/en/get-started">git + pull requests</a>
|
|
 <b>Further:</b> <a href="https://argo-cd.readthedocs.io/">Argo CD</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift GitOps</a> ·
|
|
<a href="https://external-secrets.io/">External Secrets</a> ·
|
|
<a href="https://learn.microsoft.com/azure/key-vault/">Azure Key Vault</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE VII ============ -->
|
|
<section class="course" id="c7" data-scene="supply" aria-labelledby="c7h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course VII · The image supply chain</p>
|
|
<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
|
|
mutable pointer anyone with push rights can peel off one image and press onto another, and
|
|
nothing anywhere records that it moved. The digest is the image's actual name: same bytes,
|
|
same digest, forever.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_SUPPLY__"
|
|
alt="The image journey: a layered image stack, an upstream registry tower, a squat
|
|
pull-through mirror and a node core - beneath a ghost tag plate and an engraved
|
|
digest seal floating side by side.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>Say the name properly</h3>
|
|
<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 promise somebody keeps, until they
|
|
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
|
|
answer.</p>
|
|
<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.
|
|
Nobody changed the YAML.</p>
|
|
<h3>Why a fleet pulls once</h3>
|
|
<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
|
|
<b>mirror</b>: a pull-through cache like zot. The fleet asks the mirror, the mirror asks
|
|
upstream once, everything after is local. Rate limits, egress cost, disconnected sites,
|
|
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
|
|
only</b>, so a by-tag pull silently skips the mirror. The pinning habit is what makes the
|
|
mirror actually catch the traffic.</p>
|
|
<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
|
|
directly. A new image ripples outward in layers - upstream to the centre, centre to each
|
|
site as it asks, site to its nodes over the LAN - instead of every site hammering upstream
|
|
at the same moment. Upstream sees one consumer; each site sees one hop; the nodes never
|
|
leave the building.</p>
|
|
<p class="sayit">Mirrors tier: the load fans out in layers, never all at once.</p>
|
|
<h3>Build once, promote by copy</h3>
|
|
<p>Every rebuild is a different artefact - different digest, untested by the stages before it.
|
|
So build <b>once</b>, then promote the same digest through environments by copying, registry
|
|
to registry - dev proves the exact bytes prod will run. 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="closeline">Names that can move are convenient exactly until they move. Address content
|
|
by what it is, and the supply chain stops being a chain of trust and becomes a chain of
|
|
arithmetic.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-I ·
|
|
<a href="https://kubernetes.io/docs/concepts/containers/images/">Kubernetes images</a>
|
|
 <b>Further:</b> <a href="https://zotregistry.dev/">zot</a> ·
|
|
<a href="https://learn.microsoft.com/azure/container-registry/">Azure Container Registry</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift image mirroring</a> ·
|
|
<a href="https://github.com/containers/skopeo">skopeo</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ COURSE VIII ============ -->
|
|
<section class="course" id="c8" data-scene="helm" aria-labelledby="c8h">
|
|
<header class="chead">
|
|
<p class="eyebrow">Course VIII · Helm</p>
|
|
<h2 id="c8h" class="thesis">A chart is a function.<br><em>The values file is the cluster speaking.</em></h2>
|
|
<p class="lede">The default way to run one app on five clusters is five copies of the YAML, and
|
|
the default result is five slightly different apps. The inversion: stop copying outputs and
|
|
ship the function. A chart is a template with holes; each cluster supplies one small values
|
|
file that fills them.</p>
|
|
</header>
|
|
<div class="pin">
|
|
<div class="viewport">
|
|
<figure class="scene">
|
|
<img class="flat" src="__HERO_HELM__"
|
|
alt="The Helm press: an engraved chart plate with empty sockets, four values crystals
|
|
feeding in, three rendered sheets fanned out in different hues, and a schema gate
|
|
wedge with a rejected grey sheet stopped behind it.">
|
|
</figure>
|
|
<ol class="legend"></ol>
|
|
</div>
|
|
<div class="bench"></div>
|
|
</div>
|
|
<div class="prose">
|
|
<h3>It's Go under the braces</h3>
|
|
<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 named
|
|
functions every manifest calls. You are not writing YAML - you are writing a program whose
|
|
output is YAML. So render locally, read the output, and lint what came out, not what went in.</p>
|
|
<p class="sayit">Review the render, not just the template.</p>
|
|
<p class="fieldnote"><b>Field note.</b> ArgoCD deploys charts by running
|
|
<span class="mono">helm template</span> - a pure render. Anything needing a live cluster or an
|
|
install lifecycle (<span class="mono">lookup</span>, hooks) is silently dead there. Render the
|
|
way your deployer renders.</p>
|
|
<h3>Contexts: the cluster's whole voice is one small file</h3>
|
|
<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
|
|
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,
|
|
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>
|
|
<p class="sayit">The chart owns the shape. The context owns the numbers.</p>
|
|
<h3>Make the template refuse</h3>
|
|
<p>A template that renders whatever it is given just moves the failure downstream. The grown-up
|
|
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
|
|
later as enforcement pointed at nothing.</p>
|
|
<p class="fieldnote"><b>Field note.</b> The failure you want is the render that refuses. It
|
|
costs a red pipeline. The alternative reports healthy the whole time.</p>
|
|
<p class="closeline">Fleet consistency is not a discipline problem, it is a construction
|
|
problem. Divergence stops being something you police and becomes something the tooling cannot
|
|
express.</p>
|
|
<p class="reads"><b>Pre-reads:</b> C-II ·
|
|
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/">Kubernetes objects</a>
|
|
 <b>Further:</b> <a href="https://helm.sh/docs/">Helm docs</a> ·
|
|
<a href="https://helm.sh/docs/chart_template_guide/">chart template guide</a> ·
|
|
<a href="https://pkg.go.dev/text/template">Go text/template</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">Helm on OpenShift</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============ APPENDIX ============ -->
|
|
<section class="course appendix" id="apx" aria-labelledby="apxh">
|
|
<header class="chead">
|
|
<p class="eyebrow">Appendix · The dependency ledger</p>
|
|
<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
|
|
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>
|
|
</header>
|
|
<figure class="still">
|
|
<img src="__HERO_APPENDIX__"
|
|
alt="Three familiar machines at rest: the etched repository crystal, the armoured secret
|
|
vault, and the mirror way-station - the supporting cast of the delivery arc.">
|
|
<figcaption>You have met these three before.</figcaption>
|
|
</figure>
|
|
<div class="cards">
|
|
<article class="card">
|
|
<h3>GitHub</h3>
|
|
<p class="what">Where the desired state lives - the system of record the whole loop watches,
|
|
through a read-only deploy key.</p>
|
|
<p class="leans"><b>Leans on it:</b> sync, rollback, change review, the "who merged this"
|
|
answer.</p>
|
|
<p class="down"><b>When it is down:</b> the cluster does not care - the reconciler enforces
|
|
the last synced state indefinitely. What stops is <i>change</i>. GitOps degrades to
|
|
read-only, which is the graceful half of the design.</p>
|
|
</article>
|
|
<article class="card">
|
|
<h3>Azure Key Vault</h3>
|
|
<p class="what">Where the secret values live - git carries the reference, the vault carries
|
|
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
|
|
needs a credential.</p>
|
|
<p class="down"><b>When it is down:</b> already-synced Secrets keep working - values are
|
|
materialised in-cluster. What stops is <i>rotation</i> and new secrets. Survivable - unless
|
|
you are inside a rotation window.</p>
|
|
</article>
|
|
<article class="card">
|
|
<h3>zot</h3>
|
|
<p class="what">Where the fleet pulls from - a pull-through mirror between the cluster and the
|
|
internet, and the control point for what enters. At fleet scale it tiers: one central
|
|
mirror in the cloud fans out to per-site mirrors, layering the load.</p>
|
|
<p class="leans"><b>Leans on it:</b> every image pull on every node - boot, reschedule,
|
|
scale-up, recovery.</p>
|
|
<p class="down"><b>When it is down:</b> the sharpest edge. Upstream down + mirror up = nobody
|
|
notices. Mirror down on a mirror-only pull path = nothing new schedules, and a rebooting
|
|
node may not come back whole.</p>
|
|
</article>
|
|
</div>
|
|
<div class="prose">
|
|
<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
|
|
values, and let the cluster hold its last known truth without asking anyone's permission.</p>
|
|
<p class="reads"><b>Further:</b> <a href="https://docs.github.com/">GitHub docs</a> ·
|
|
<a href="https://learn.microsoft.com/azure/key-vault/">Azure Key Vault</a> ·
|
|
<a href="https://zotregistry.dev/">zot</a> ·
|
|
<a href="https://kubernetes.io/docs/">Kubernetes</a> ·
|
|
<a href="https://helm.sh/docs/">Helm</a> ·
|
|
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">Red Hat OpenShift</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="course" id="flow" aria-labelledby="flowh">
|
|
<header class="chead">
|
|
<p class="eyebrow">The whole arc · end to end</p>
|
|
<h2 id="flowh" class="thesis">One flow, <em>no gaps.</em></h2>
|
|
<p class="lede">Every course above is one stretch of the same journey. Here is the full run,
|
|
drawn in the house blueprint style: the change lane, the shape lane, the artefact lane and
|
|
the secret lane, all converging on one running workload.</p>
|
|
</header>
|
|
<figure class="blueprint">
|
|
<img src="__DIAG_FLOW__"
|
|
alt="End-to-end delivery flow. Change lane: a commit lands in the GitHub repository, ArgoCD
|
|
renders and diffs it, and syncs the cluster - the cluster pulls, nothing pushes.
|
|
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
|
|
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
|
|
them in - git only ever holds the reference. All four lanes converge on the running
|
|
workload.">
|
|
</figure>
|
|
</section>
|
|
|
|
<section class="course" id="library" aria-labelledby="libh">
|
|
<header class="chead">
|
|
<p class="eyebrow">The library</p>
|
|
<h2 id="libh" class="thesis">Go to <em>the sources.</em></h2>
|
|
<p class="lede">Every technology this site teaches, one sentence each, official documentation
|
|
only.</p>
|
|
</header>
|
|
<ul class="library">
|
|
<li><b>Kubernetes</b><span>The container orchestrator underneath everything here: declarative
|
|
state, the reconciliation loop, pods, services and the machinery that keeps promises.</span>
|
|
<a href="https://kubernetes.io/docs/">kubernetes.io/docs</a></li>
|
|
<li><b>Helm</b><span>The package manager and Go-template engine that turns one chart plus a
|
|
per-cluster values file into the rendered manifests a cluster actually runs.</span>
|
|
<a href="https://helm.sh/docs/">helm.sh/docs</a></li>
|
|
<li><b>Red Hat OpenShift</b><span>Kubernetes as an opinionated, supported distribution -
|
|
SCCs, Routes and Operators, plus the fleet tooling (RHACM, zero-touch provisioning) that
|
|
assigns apps to clusters by label.</span>
|
|
<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
|
|
converged on what it finds there - the pull model that means nothing ever pushes to
|
|
production.</span>
|
|
<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
|
|
artefacts and proposes changes, and never holds a credential that can touch a cluster.</span>
|
|
<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
|
|
references and rotation never has to become a commit.</span>
|
|
<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
|
|
the digest is minted that everything downstream pins and promotes.</span>
|
|
<a href="https://learn.microsoft.com/azure/container-registry/">learn.microsoft.com/container-registry</a></li>
|
|
</ul>
|
|
</section>
|
|
|
|
<footer class="outro">
|
|
<p>The Exploded Cluster · every scene is one generated hero, sliced and scrubbed -
|
|
no diagrams were harmed.</p>
|
|
<p>© 2026 Jonathon Wright · <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>
|
|
· built with GSAP, shipped via GitOps · <a href="https://www.bztmon.com/">bztmon.com</a></p>
|
|
</footer>
|
|
|
|
<style>
|
|
:root{
|
|
--void:#070b14; --ink:#c9d7ef; --dim:#7286a8; --line:#1b2740;
|
|
--cyan:#3fbaf5; --magenta:#e879f9; --amber:#e8b44a;
|
|
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--void);color:var(--ink);font-family:var(--sans);
|
|
-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
|
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}
|
|
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}
|
|
.eyebrow{font-family:var(--mono);font-size:.72rem;letter-spacing:.22em;text-transform:uppercase;
|
|
color:var(--cyan);margin:0 0 1.5rem}
|
|
.masthead h1,.thesis{font-size:clamp(1.9rem,4.8vw,3.2rem);line-height:1.1;margin:0 0 1.3rem;
|
|
font-weight:600;letter-spacing:-.02em;text-wrap:balance}
|
|
.masthead h1 em,.thesis em{font-style:normal;color:var(--magenta)}
|
|
.lede{font-size:clamp(.98rem,1.6vw,1.12rem);line-height:1.65;color:var(--dim);margin:0 auto;max-width:56ch}
|
|
.scrollcue{font-family:var(--mono);font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;
|
|
color:var(--dim);margin-top:4.5rem;display:flex;flex-direction:column;align-items:center;gap:.7rem}
|
|
.scrollcue span{display:block;width:1px;height:46px;background:linear-gradient(var(--cyan),transparent)}
|
|
|
|
.toc{max-width:760px;margin:0 auto;padding:0 24px 5vh}
|
|
.toc .kt,.purpose .kt{font-family:var(--mono);font-size:.68rem;letter-spacing:.16em;
|
|
text-transform:uppercase;color:var(--dim);text-align:center;margin:0 0 1rem}
|
|
.toc ol{list-style:none;margin:0;padding:0;counter-reset:toc;display:flex;flex-direction:column;gap:8px}
|
|
.toc li{counter-increment:toc}
|
|
.toc a{display:grid;grid-template-columns:34px 110px 1fr;gap:12px;align-items:baseline;
|
|
border:1px solid var(--line);border-radius:8px;padding:10px 16px;background:#0a1020;
|
|
border-bottom-width:1px;text-decoration:none}
|
|
.toc a::before{content:counter(toc,decimal-leading-zero);font-family:var(--mono);
|
|
font-size:.68rem;color:var(--dim)}
|
|
.toc a:hover,.toc a:focus-visible{border-color:var(--cyan)}
|
|
.toc b{font-family:var(--mono);font-size:.72rem;letter-spacing:.08em;color:var(--cyan);font-weight:600}
|
|
.toc span{font-size:.82rem;line-height:1.5;color:var(--dim)}
|
|
@media (max-width:640px){.toc a{grid-template-columns:34px 1fr}.toc span{grid-column:2}}
|
|
|
|
.purpose{max-width:66ch;margin:0 auto;padding:0 24px 5vh}
|
|
.purpose p:not(.kt){font-size:.95rem;line-height:1.75;color:var(--dim);margin:0;text-align:left}
|
|
|
|
.library{max-width:860px;margin:2vh auto 6vh;padding:0 24px;list-style:none;
|
|
display:flex;flex-direction:column;gap:12px}
|
|
.library li{display:grid;grid-template-columns:190px 1fr auto;gap:16px;align-items:baseline;
|
|
border:1px solid var(--line);border-radius:8px;padding:14px 18px;background:#0a1020}
|
|
.library b{font-family:var(--mono);font-size:.78rem;letter-spacing:.06em;color:var(--cyan)}
|
|
.library span{font-size:.85rem;line-height:1.6;color:var(--dim)}
|
|
.library a{font-family:var(--mono);font-size:.68rem;letter-spacing:.04em;white-space:nowrap}
|
|
@media (max-width:760px){.library li{grid-template-columns:1fr}.library a{justify-self:start}}
|
|
|
|
.blueprint{max-width:1220px;margin:2vh auto 4vh;padding:0 24px}
|
|
.blueprint img{width:100%;height:auto;display:block;border-radius:12px}
|
|
|
|
.course{position:relative;padding-top:10vh}
|
|
.chead{max-width:64ch;margin:0 auto;padding:4vh 24px 2vh;text-align:center}
|
|
.chead .thesis{font-size:clamp(1.6rem,4vw,2.6rem)}
|
|
.pin{min-height:100svh;display:grid;place-items:center;padding:3vh 20px;align-content:center}
|
|
.viewport{width:min(1220px,100%);display:grid;grid-template-columns:minmax(0,1fr) 290px;
|
|
gap:26px;align-items:center}
|
|
.scene{position:relative;margin:0;line-height:0}
|
|
.scene .flat{display:block;width:100%;height:auto}
|
|
.slab{position:absolute;inset:0;background-size:100% 100%;background-repeat:no-repeat;will-change:transform}
|
|
|
|
.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);
|
|
opacity:.34;transition:opacity .35s ease,border-color .35s ease}
|
|
.legend li.lit{opacity:1;border-left-color:var(--cyan)}
|
|
.legend .t{font-size:.92rem;font-weight:600;margin:0 0 .22rem;letter-spacing:-.01em}
|
|
.legend .b{font-size:.76rem;line-height:1.5;color:var(--dim);margin:0}
|
|
|
|
.viewport.solo{grid-template-columns:1fr;max-width:980px}
|
|
.crt{position:absolute;inset:22% 26%;display:flex;flex-direction:column;justify-content:center;
|
|
gap:.35rem;padding:clamp(10px,2.5vw,26px);border:1px solid var(--line);border-radius:10px;
|
|
background:rgba(5,8,16,0.88);box-shadow:0 0 40px rgba(63,186,245,0.07) inset}
|
|
.crt .line{font-family:var(--mono);font-size:clamp(.55rem,1.35vw,.85rem);line-height:1.5;
|
|
color:var(--ink);margin:0;white-space:nowrap;overflow:hidden}
|
|
.crt .line.ok{color:var(--dim)}
|
|
.crt .line.q{color:var(--magenta);margin-top:.5rem}
|
|
body.fx .crt .line{opacity:0;transform:translateY(4px);transition:opacity .3s ease,transform .3s ease}
|
|
body.fx .crt .line.on{opacity:1;transform:none}
|
|
@media (max-width:700px){.crt{inset:16% 12%}}
|
|
|
|
.bench{width:min(720px,100%);margin:16px auto 0;text-align:center}
|
|
.bench input{width:100%;accent-color:var(--cyan)}
|
|
.bench .hud{font-family:var(--mono);font-size:.66rem;letter-spacing:.06em;color:var(--dim);margin:.45rem 0 0}
|
|
|
|
.prose{max-width:66ch;margin:0 auto;padding:5vh 24px 9vh}
|
|
.prose h3{font-size:1.18rem;font-weight:600;letter-spacing:-.01em;margin:2.6rem 0 .8rem;color:var(--ink)}
|
|
.prose p{font-size:.98rem;line-height:1.75;color:var(--dim);margin:0 0 1.1rem}
|
|
.sayit{font-weight:600;color:var(--cyan) !important;border-left:2px solid var(--cyan);padding-left:18px}
|
|
.fieldnote{border-left:2px solid var(--amber);padding-left:18px;font-size:.92rem !important}
|
|
.fieldnote b{color:var(--amber);font-family:var(--mono);font-size:.7rem;letter-spacing:.16em;
|
|
text-transform:uppercase;display:block;margin-bottom:.35rem}
|
|
.closeline{color:var(--ink) !important;margin-top:2.2rem !important}
|
|
.reads{font-size:.8rem !important;line-height:1.9 !important;border-top:1px solid var(--line);
|
|
padding-top:1.2rem;margin-top:2.4rem !important}
|
|
.reads b{font-family:var(--mono);font-size:.68rem;letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
|
|
|
|
.appendix .still{max-width:900px;margin:2vh auto 0;padding:0 24px}
|
|
.appendix .still img{width:100%;height:auto;display:block}
|
|
.appendix .still figcaption{font-family:var(--mono);font-size:.7rem;letter-spacing:.14em;
|
|
text-transform:uppercase;color:var(--dim);text-align:center;margin-top:.9rem}
|
|
.cards{max-width:1100px;margin:5vh auto 0;padding:0 24px;display:grid;
|
|
grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}
|
|
.card{border:1px solid var(--line);border-radius:10px;padding:22px 22px 18px;background:#0a1020}
|
|
.card h3{margin:0 0 .7rem;font-size:1.05rem;letter-spacing:-.01em}
|
|
.card p{font-size:.85rem;line-height:1.65;color:var(--dim);margin:0 0 .9rem}
|
|
.card .leans{border-left:2px solid var(--cyan);padding-left:14px}
|
|
.card .down{border-left:2px solid var(--amber);padding-left:14px;margin-bottom:0}
|
|
.card .leans b,.card .down b{font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;
|
|
text-transform:uppercase;display:block;margin-bottom:.3rem}
|
|
.card .leans b{color:var(--cyan)} .card .down b{color:var(--amber)} .card i{color:var(--ink);font-style:normal;font-weight:600}
|
|
|
|
.outro{max-width:64ch;margin:0 auto;padding:6vh 24px 14vh;text-align:center}
|
|
.outro p{font-family:var(--mono);font-size:.7rem;letter-spacing:.12em;color:var(--dim);line-height:1.9}
|
|
|
|
@media (max-width:900px){
|
|
.viewport{grid-template-columns:1fr;gap:20px}
|
|
.pin{min-height:0;padding:3vh 18px 5vh}
|
|
.masthead{padding:9vh 22px 6vh}
|
|
}
|
|
body:not(.fx) .slab{display:none}
|
|
body:not(.fx) .bench{display:none}
|
|
</style>
|
|
|
|
<script>
|
|
(() => {
|
|
"use strict";
|
|
const SCENES = { surround: __MAN_SURROUND__, docker: __MAN_DOCKER__, cluster: __MAN_CLUSTER__, node: __MAN_NODE__, pod: __MAN_POD__, traffic: __MAN_TRAFFIC__, orbit: __MAN_ORBIT__, gitops: __MAN_GITOPS__, supply: __MAN_SUPPLY__, helm: __MAN_HELM__ };
|
|
const RM = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const units = [];
|
|
|
|
document.querySelectorAll("section[data-scene]").forEach(sec => {
|
|
const man = SCENES[sec.dataset.scene];
|
|
const scene = sec.querySelector(".scene");
|
|
const flat = scene.querySelector(".flat");
|
|
const legend = sec.querySelector(".legend");
|
|
if (legend) man.parts.forEach(p => {
|
|
const li = document.createElement("li");
|
|
li.innerHTML = '<p class="t">' + p.title + '</p><p class="b">' + p.blurb + '</p>';
|
|
legend.appendChild(li);
|
|
});
|
|
units.push({ sec, man, scene, flat, legend });
|
|
});
|
|
|
|
if (!window.gsap || !window.ScrollTrigger) return; // no-JS/no-GSAP: flat heroes + full legends
|
|
document.body.classList.add("fx");
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
units.forEach(u => {
|
|
u.slabs = u.man.parts.map(p => {
|
|
const d = document.createElement("div");
|
|
d.className = "slab";
|
|
d.style.backgroundImage = "url(" + u.flat.src + ")";
|
|
d.style.clipPath = "polygon(" + p.points.map(q => q[0] + "% " + q[1] + "%").join(",") + ")";
|
|
if (p.z) d.style.zIndex = String(p.z);
|
|
u.scene.appendChild(d);
|
|
return d;
|
|
});
|
|
u.flat.style.visibility = "hidden";
|
|
const lis = u.legend ? [...u.legend.children] : [];
|
|
const n = lis.length;
|
|
u.apply = t => {
|
|
// stagger > 0 = cascade: parts complete their travel one after another in list order
|
|
const sg = u.man.stagger || 0;
|
|
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;
|
|
// reverse scenes sit assembled at rest and FLY APART as the scrub advances
|
|
const e = u.man.reverse ? (1 - pt) : pt;
|
|
u.slabs[i].style.transform =
|
|
"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) + ")";
|
|
// the side list lights in reading order, top to bottom, as the scrub advances
|
|
lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.9 * ((i + 0.5) / n)));
|
|
};
|
|
// bench: hand scrub + HUD (pilot instrument - strip for the site ship)
|
|
const bench = u.sec.querySelector(".bench");
|
|
bench.innerHTML = '<input type="range" min="0" max="1000" value="0" aria-label="scrub the teardown">' +
|
|
'<p class="hud">boot</p>';
|
|
u.hud = bench.querySelector(".hud");
|
|
u.slider = bench.querySelector("input");
|
|
u.show = v => {
|
|
u.hud.textContent = u.mode + " · " + innerWidth + "x" + innerHeight +
|
|
" · scroll " + Math.round(scrollY) + " · t=" + v.toFixed(2);
|
|
u.slider.value = String(Math.round(v * 1000));
|
|
};
|
|
u.drive = v => { u.apply(v); u.show(v); };
|
|
u.slider.addEventListener("input", () => u.drive(+u.slider.value / 1000));
|
|
});
|
|
|
|
const boot = () => {
|
|
const scrollable = document.documentElement.scrollHeight - innerHeight > 200;
|
|
units.forEach(u => {
|
|
if (RM) { u.mode = "reduced-motion"; u.drive(1); return; }
|
|
if (scrollable && matchMedia("(min-width: 901px)").matches) {
|
|
u.mode = "pin+scrub";
|
|
ScrollTrigger.create({
|
|
trigger: u.sec.querySelector(".pin"),
|
|
start: "top top",
|
|
end: "+=1800",
|
|
pin: true,
|
|
scrub: 0.6,
|
|
onUpdate: self => u.drive(self.progress),
|
|
onRefreshInit: () => u.apply(0)
|
|
});
|
|
u.drive(0);
|
|
} else {
|
|
u.mode = "assemble";
|
|
u.sec.querySelector(".pin").style.minHeight = "0";
|
|
u.drive(0);
|
|
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",
|
|
onUpdate: function () { u.drive(this.targets()[0].v); } });
|
|
}, { threshold: 0.2 });
|
|
io.observe(u.scene);
|
|
}
|
|
});
|
|
if (!RM) ScrollTrigger.refresh();
|
|
};
|
|
// the aperitif terminal types its transcript once, when it first enters view
|
|
document.querySelectorAll(".crt").forEach(crt => {
|
|
const lines = [...crt.querySelectorAll(".line")];
|
|
if (RM) { lines.forEach(l => l.classList.add("on")); return; }
|
|
const io = new IntersectionObserver(es => {
|
|
if (!es.some(x => x.isIntersecting)) return;
|
|
io.disconnect();
|
|
lines.forEach((l, i) => setTimeout(() => l.classList.add("on"), 350 * i + 200));
|
|
}, { threshold: 0.4 });
|
|
io.observe(crt);
|
|
});
|
|
|
|
Promise.all(units.map(u => u.flat.decode ? u.flat.decode().catch(() => {}) : Promise.resolve()))
|
|
.then(() => requestAnimationFrame(() => requestAnimationFrame(boot)));
|
|
})();
|
|
</script>
|