Files
exploded-cluster/pilot/arc.tpl.html
T

905 lines
55 KiB
HTML

<header class="masthead">
<p class="eyebrow">The Exploded Cluster &middot; 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>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="#c3a"><b>Course IIIa</b><span>The node - where intent becomes a process</span></a></li>
<li><a href="#c3b"><b>Course IIIb</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&#39;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 &middot; 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: podman build, podman push,
oc apply, oc get pods showing Running - then the question: but what
actually just happened?">
<p class="line">$ podman build -t shop/api:1.4.2 .</p>
<p class="line ok">=&gt; exported image sha256:9f2c41...</p>
<p class="line">$ podman push shop/api:1.4.2</p>
<p class="line">$ oc apply -f deploy.yaml</p>
<p class="line ok">deployment.apps/api created</p>
<p class="line">$ oc get pods</p>
<p class="line ok">api-7d4b9c6-x2k4f&nbsp;&nbsp;1/1&nbsp;&nbsp;Running</p>
<p class="line q">...but what actually just happened?</p>
</div>
</figure>
</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 &middot; Podman</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>
<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 <b>read-only</b> layers, each
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
fetches 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>
<p>The top slab in the scene is the odd one out: the <b>writable layer is not part of the
image</b>. The runtime adds it when it creates the <i>container</i>, and discards it when
that container dies. Nothing written there ships, and nothing written 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 Containerfile 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 Containerfile 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
&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>
</div>
</section>
<!-- ============ COURSE II ============ -->
<section class="course" id="c2" data-scene="cluster" aria-labelledby="c2h">
<header class="chead">
<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>
<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>
<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">oc 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
&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>
</div>
</section>
<!-- ============ COURSE III - THE NODE ============ -->
<section class="course" id="c3a" data-scene="node" aria-labelledby="c3ah">
<header class="chead">
<p class="eyebrow">Course IIIa &middot; 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>
<div class="prose">
<h3>The kubelet decides; the runtime does</h3>
<p>Every node runs a <b>kubelet</b> - the agent that owns what should be running there. It does
not create containers itself. It speaks <b>CRI</b> to <b>containerd</b> or CRI-O, and that
runtime pulls the image (through the mirror of Course VII) and actually creates and starts
the container, handing the low-level work to runc or crun. 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 and the runtime executes - and it is the
runtime, not the kubelet, that calls the CNI plugin to wire the pod up.</p>
<p class="sayit">The kubelet decides what should run. The runtime is what starts it.</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 turns intent into instructions, and the runtime turns instructions into processes. Authority and execution meet exactly here, nowhere
else.</p>
<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;
<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 IIIb &middot; 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>
<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 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, 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 - unless a Service targets it by name, or you
use hostPort. Either way the app still has to bind the port itself.</p>
<p class="fieldnote"><b>Field note.</b> Exit 137 is 128 + 9: the process was <b>SIGKILLed</b>.
It does not say by whom. The kernel's OOM killer surfaces as reason OOMKilled; a failed
liveness probe shows up in the pod's events; eviction and node pressure look different again.
One exit code, several possible crimes - read the termination reason and the events, never
the number alone.</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
&ensp;<b>Further:</b> <a href="https://kubernetes.io/docs/concepts/workloads/pods/">pods</a> &middot;
<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 &middot; 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>
<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>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
individually by stable DNS name. For ordinary application traffic, though, the Service is
the only address worth knowing.</p>
<p class="sayit">For application traffic: 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)
&ensp;<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 &middot; 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>
<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 machinery on your behalf -
though 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
OperatorHub. 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
&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>
</div>
</section>
<!-- ============ COURSE VI ============ -->
<section class="course" id="c6" data-scene="gitops" aria-labelledby="c6h">
<header class="chead">
<p class="eyebrow">Course VI &middot; 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 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
from git. oc 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>
<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 change history is git log; code review <i>is</i> change control. Git is the
record of <i>intent</i> - the cluster's own audit log and the reconciler's sync history still
record what actually happened, including everything git never sees.</p>
<p class="sayit">oc is for archaeology. git is for change.</p>
<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
is the stronger variant, and worth choosing deliberately: no CI system, no laptop, no build
pipeline holds a credential that can touch the cluster, because 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 <i>through git</i>. It
can still push images - which is the other half of why a manifest should name the digest, not
the tag. Hand-edit a live object
and the controller flags it OutOfSync - <b>with selfHeal enabled</b> it puts the object back,
and <b>with prune enabled</b> what leaves git leaves the cluster. Both are opt-in: without
them the reconciler reports the drift and waits for a human. 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 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
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
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. Commit a plaintext secret and you should treat it as
<b>compromised from that moment</b>: deleting it later does not guarantee it is gone from
history, forks, clones, CI caches or backups. 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 &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>
&ensp;<b>Further:</b> <a href="https://argo-cd.readthedocs.io/">Argo CD</a> &middot;
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift GitOps</a> &middot;
<a href="https://external-secrets.io/">External Secrets</a> &middot;
<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 &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>
<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 - sha256 of the image <i>manifest</i> - is
its actual name: same bytes, same digest, forever. (Careful: the sha256 a build prints is the
local image ID, a different hash from the manifest digest the registry mints on push - the
pushed one is what you pin.)</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>
<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 them - unless you also add an
<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>
<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 <i>new</i> artefact - in practice a different digest (reproducible builds
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
to registry - dev proves the exact bytes prod will run. On a multi-arch image that copy needs
<span class="mono">--all</span> (and <span class="mono">--preserve-digests</span> to fail
loudly rather than quietly), or you copy one architecture and the digest you promoted is not
the digest that lands. 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 &middot;
<a href="https://kubernetes.io/docs/concepts/containers/images/">Kubernetes images</a>
&ensp;<b>Further:</b> <a href="https://zotregistry.dev/">zot</a> &middot;
<a href="https://learn.microsoft.com/azure/container-registry/">Azure Container Registry</a> &middot;
<a href="https://docs.redhat.com/en/documentation/openshift_container_platform">OpenShift image mirroring</a> &middot;
<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 &middot; 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>
<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
<b>named templates</b> - the partials every manifest <span class="mono">include</span>s, not
functions you can call bare. 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 renders charts with
<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>
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
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>
<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 &middot;
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/">Kubernetes objects</a>
&ensp;<b>Further:</b> <a href="https://helm.sh/docs/">Helm docs</a> &middot;
<a href="https://helm.sh/docs/chart_template_guide/">chart template guide</a> &middot;
<a href="https://pkg.go.dev/text/template">Go text/template</a> &middot;
<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 &middot; 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> Kubernetes keeps running the last applied state
indefinitely. The reconciler keeps self-healing only while its rendered manifests are
still cached - hours, not forever, and gone after a restart. 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, <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
holds the image, and <span class="mono">imagePullPolicy: Always</span> turns a mirror
outage into a hard stop.</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> &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://kubernetes.io/docs/">Kubernetes</a> &middot;
<a href="https://helm.sh/docs/">Helm</a> &middot;
<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 &middot; 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>&copy; 2026 Jonathon Wright &middot; <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>
&middot; built with GSAP, shipped via GitOps &middot; <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:18% 22%;display:flex;flex-direction:column;justify-content:center;
gap:.3rem;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;padding-bottom:.12em}
.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:10% 8%;gap:.2rem}.crt .line{line-height:1.7}}
.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}
</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)));
};
});
const boot = () => {
const scrollable = document.documentElement.scrollHeight - innerHeight > 200;
units.forEach(u => {
if (RM) { u.mode = "reduced-motion"; u.apply(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.apply(self.progress),
onRefreshInit: () => u.apply(0)
});
u.apply(0);
} else {
u.mode = "assemble";
u.sec.querySelector(".pin").style.minHeight = "0";
u.apply(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.apply(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>