Files
jwright ff2b8dabdc learn site: navigable chapter headers and a richer landing masthead
Both chapters now carry a back link to the index and a chapter number in the
eyebrow, so the section reads as a series rather than three separate pages.
The Mirror gains a standfirst, a gradient title and a four-cell fact strip;
the landing page gains an accent rule, a stats strip and a hover wash on the
cards. The forming chapter now reads Coming soon rather than To be decided.
2026-08-25 18:47:16 +10:00

1122 lines
70 KiB
HTML

<header class="masthead">
<div class="introbg" aria-hidden="true"><img src="__HERO_INTRO__" alt=""></div>
<nav class="chapnav" aria-label="Site"><a href="/">&larr; The Teaching Lab</a></nav>
<p class="eyebrow">Chapter 01 &middot; 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="Course index">
<p class="kt">Index</p>
<ol>
<li><a href="#c0">Course 00<span>Aperitif - three delivery actions and one check</span></a></li>
<li><a href="#c1">Course I<span>Podman - an image is a stack of frozen diffs</span></a></li>
<li><a href="#c2">Course II<span>Kubernetes - a cluster is a promise, not a place</span></a></li>
<li><a href="#c3a">Course IIIa<span>The node - where intent becomes a process</span></a></li>
<li><a href="#c3b">Course IIIb<span>The pod - one IP, shared fate</span></a></li>
<li><a href="#c4">Course IV<span>The traffic - pods die constantly, the address does not</span></a></li>
<li><a href="#c5">Course V<span>OpenShift - Kubernetes with opinions, and a security guard</span></a></li>
<li><a href="#c6">Course VI<span>GitOps - nobody deploys anything; the cluster syncs itself</span></a></li>
<li><a href="#c7">Course VII<span>The image supply chain - a tag is a promise, a digest is a fact</span></a></li>
<li><a href="#c8">Course VIII<span>Helm - a chart is a function; the values file is the cluster speaking</span></a></li>
<li><a href="#apx">Appendix<span>The dependency ledger - GitHub, Azure Key Vault, zot</span></a></li>
<li><a href="#flow">Blueprint<span>The delivery flow, end to end</span></a></li>
<li><a href="#library">Library<span>Official documentation for every technology on this page</span></a></li>
</ol>
</nav>
<section class="purpose" aria-labelledby="purph">
<h2 class="kt" id="purph">What this teaches</h2>
<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.
OpenShift is the teaching lens. <span class="mono">oc</span> is the command language. Kubernetes
primitives explain the machinery underneath. Occasional lab notes show a separate Kubernetes
environment where a concept was observed; they are not a claim that the lab runs OpenShift.
Start with the aperitif&#39;s four terminal commands - three that deliver, one that checks - and
finish knowing how a change travels from a Git commit to a running, secret-fed, digest-pinned workload on a fleet. The library at the end links only to official documentation, so every claim here can be
checked against its source.</p>
</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 delivery actions. One check.<br><em>What actually just happened?</em></h2>
<p class="lede">The whole ceremony of shipping software fits in four lines of terminal. They
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 ghcr.io/shop/api:1.4.2 .</p>
<p class="line ok">=&gt; exported image sha256:9f2c41...</p>
<p class="line">$ podman push ghcr.io/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 class="aside">A note on the commands: this site uses <span class="mono">oc</span>,
OpenShift's CLI. Examples use <span class="mono">oc</span>, the OpenShift CLI, against
Kubernetes API resources - for those, <span class="mono">kubectl</span> behaves identically.
Where a step needs OpenShift APIs (SCCs, Routes, ClusterOperators, MachineConfig), it will
not exist on a plain Kubernetes cluster, and the text says so.</p>
<p>That <span class="mono">1/1</span> reads as containers-ready over containers-wanted: a pod
can hold more than one, which is Course IIIb.</p>
<p>Three of those were delivery actions - build, push, apply, and the
last, <span class="mono">oc get pods</span>, only checked the result. Built of what, exactly?
Pushed to where, and what travelled? Applied, which is not the same as launched. Running,
according to whom? Each course below takes one of those words apart.</p>
</div>
</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 container is a process, not a machine</h3>
<p>If you arrived here from Linux, take this translation first: a running container is an
ordinary process on your kernel. No guest OS, no hypervisor. The kernel gives it
namespaces so it sees its own PID tree, mounts, network and hostname, and
cgroups so its CPU and memory can be capped. <span class="mono">ps</span> on the host
lists it. <span class="mono">kill</span> on the host kills it. Podman leans into that: no
daemon sits in the middle - the container is a child of your own shell, and rootless mode
maps your user onto root <i>inside</i> the container through a user namespace, so root in
there is an unprivileged UID out here.</p>
<p>So what does the image provide? The filesystem that process sees. That is the whole
job, and it is why an image is a stack of layers rather than a disk image.</p>
<p class="fieldnote"><b>Field note.</b> Prove it on your own box.
<span class="mono">podman run -d --name t alpine sleep 300</span>, then on the
<i>host</i>: <span class="mono">ps -ef | grep sleep</span> finds the process,
<span class="mono">lsns -p &lt;pid&gt;</span> lists the namespaces it was handed,
<span class="mono">cat /proc/&lt;pid&gt;/cgroup</span> shows where its limits live, and
<span class="mono">mount | grep overlay</span> shows the layers stitched together. None of
it is exotic. It is your kernel, described differently.</p>
<h3>What an image is made of</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. Layers are content-addressed, so an
identical layer is stored once and reused by every image that references it, so a pull
fetches just the layers you do not already have. A base sits at the bottom, your dependencies on it,
your code - usually the smallest layer, and typically the most volatile - above that.</p>
<p>Those layers become one filesystem through a union mount - overlayfs, the same
kernel feature you can mount by hand. The read-only image layers are the lower dirs; the
container gets a fresh upper dir of its own. Writes land in the upper, and editing an
existing file copies it up there first, leaving the image layer untouched underneath.</p>
<p>That upper dir is the top slab in the scene, and it is the odd one out: the writable
layer is not part of the image. The runtime creates it with the <i>container</i> and
discards it when that container dies. Nothing written there ships, and nothing written there
survives, which is the entire reason volumes exist.</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 a lot of what sits above it: shared layers make
pulls fast, immutable content lets a digest name exact bytes (Course VII), and the throwaway
top layer is why persistent state needs volumes.</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 continuously 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.">
<figcaption class="fieldnote">Generic OpenShift/Kubernetes cluster model - not a diagram
of the homelab.</figcaption>
</figure>
<ol class="legend"></ol>
</div>
</div>
<div class="prose">
<h3>Desired against actual, on a loop</h3>
<p>The habit underneath everything: the reconciliation loop - 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 keeps 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 control
plane that decides - holds the truth, schedules, reconciles, and worker nodes
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 SSH into a node to "fix" a workload, a
controller may reconcile your change away. Change the desired state instead, and let the loop
carry it.</p>
<h3>What the control plane is made of</h3>
<p>The core control plane is the API server, scheduler and
controller-manager, backed by etcd; cloud deployments may also run a
cloud-controller-manager. Naming them makes the later error messages readable. The API server
(<span class="mono">kube-apiserver</span>) is the only door: requests authenticate, are
authorised and are admitted there, and it is the one component that talks to etcd, the
key-value store holding Kubernetes API state. Lose etcd and you have lost the cluster, which
is why backing it up is the homework to not skip. The scheduler decides which node a new pod
belongs on - packing by the resources a pod <i>requests</i>, not by what it currently uses -
and records that decision; it does not start containers. The controller-manager runs the
reconciliation loops. Control-plane state converges through the API server: the scheduler and
built-in controllers watch API objects and write decisions back through the API rather than
modifying etcd directly.</p>
<p>One of those loops is the chain you will debug most: a Deployment creates a
ReplicaSet, and the ReplicaSet creates pods. That is why the deploy in the
aperitif printed <span class="mono">deployment.apps/api created</span> and you then went
looking for a pod, and why, when a rollout is stuck with no pod at all, the answer is
upstream in that chain rather than on any node.</p>
<p class="sayit">etcd is the durable store for Kubernetes API state; controllers reconcile from it continually.</p>
<p class="closeline">Everything the delivery arc teaches from Course VI onward is the same
loop at a larger scale: Git as the desired state, whole fleets as reconciled objects.</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>Where a pod becomes processes</h3>
<p>The scheduler decides which node a pod belongs on; the kubelet on that node
makes the assigned PodSpec real; the runtime creates and starts the containers. The
kubelet does not place pods, and does not itself create containers - it speaks CRI (the
Container Runtime Interface) to containerd or CRI-O, and that runtime pulls the image
(through the mirror of Course VII) and starts it, handing the low-level work to runc or crun.
A CNI plugin (Container Network Interface) gives the pod a real IP - called by the
runtime, not the kubelet, and kube-proxy, or an eBPF datapath replacing it, makes
Service addresses route to real pods.</p>
<p class="sayit">The scheduler places the pod. The kubelet realises it. The runtime runs it.</p>
<p class="fieldnote"><b>Field note.</b> Node NotReady?
<span class="mono">oc describe node</span> shows its conditions and the last heartbeat, and
the node's events say what the platform already knows. When you need the node itself,
<span class="mono">oc debug node/&lt;name&gt;</span> is the supported doorway - not SSH. A
node that cannot phone home is presumed lost.</p>
<p class="closeline">The control plane does not start container processes directly. It records
and reconciles intent; node-side components execute it - the kubelet turning intent into
instructions, the runtime turning instructions into processes.</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 network namespace: 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. regular init containers run in order and
complete before the app containers start. Native sidecars are the deliberate exception:
restartable init containers that keep running alongside the app, for jobs like proxy, logs and
config reload.</p>
<h3>Three probes, three different jobs</h3>
<p>startup owns warm-up, readiness gates traffic, liveness 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 SIGKILLed.
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: <span class="mono">oc describe pod &lt;name&gt;</span> shows both together,
and <span class="mono">oc logs --previous</span> shows what the dead container said on its
way out. Worth connecting to what you already know: a memory limit becomes a
cgroup ceiling, and the kernel's OOM killer enforces it just as it would for any
other process on the box.</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 die constantly.<br><em>The address does not.</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 Service 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>How a Service finds its pods</h3>
<p>A Service holds no list of pods. It holds a label selector (say
<span class="mono">app: api</span>), and a controller continuously matches that against every
pod in the namespace. That is a Kubernetes namespace, a naming boundary for objects, unrelated
to the kernel namespaces of Course I; the passing set lands in an EndpointSlice. Labels are how
everything here finds everything else, from a Service picking pods to a fleet hub picking
whole clusters (Course VI). Wear the label and you are eligible; readiness decides whether
you stay.</p>
<p>A ClusterIP is a virtual Service address implemented by the node dataplane rather
than an application process listening on that IP. In iptables or nftables mode,
kube-proxy installs rules that steer Service traffic to endpoint IPs; if you have
written a DNAT rule by hand, that mode will feel familiar. In IPVS mode, kube-proxy binds
Service IPs to the <span class="mono">kube-ipvs0</span> dummy interface and creates IPVS
virtual servers. eBPF implementations can replace kube-proxy with their own dataplane. Cluster
DNS (CoreDNS) resolves <span class="mono">api.myns.svc.cluster.local</span> to the
Service address.</p>
<h3>Readiness decides membership</h3>
<p>When a configured readiness probe fails, the pod becomes unready and normal
Kubernetes Service traffic stops selecting it for new connections. There is no error at the
client - the pod drops out of the endpoint set. That is the feature: broken instances take
themselves out of rotation. It is also the first place to look when traffic "disappears".</p>
<p>The rule has deliberate exceptions: you address pods directly when debugging a specific
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 ordinary Service traffic: address the Service, not a pod.</p>
<p class="fieldnote"><b>Field note.</b> "The network is broken" after a deploy is usually
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. For pods with readiness probes, eligibility for Service traffic is continually
re-evaluated for the life of the pod.</p>
<p class="reads"><b>Pre-reads:</b> C-III's pod scene (readiness lives there)
&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 SCC - Security Context Constraint - is admission deciding what a pod may BE, checked
by the api-server when the pod is created and before any node sees it. Under OpenShift's restricted
SCCs, workloads normally run as a non-root UID drawn from the project's allocated range,
so an image has to work with an arbitrary permitted UID rather than assuming a fixed user.
Workloads that genuinely need privilege get a dedicated ServiceAccount bound to the minimum
SCC that grants it, not the stock one.</p>
<h3>The platform runs itself</h3>
<p>Routes predate Ingress and still rule here. An Operator is a controller paired with a
custom resource: you describe what you want in YAML, and its controller builds it and keeps it
true. An Operator applies the same reconciliation pattern to an application or platform
capability. The split matters: the platform's own operators are driven by the Cluster Version
Operator, while OLM installs and upgrades the add-on Operators you choose from
OperatorHub. MachineConfig is the supported declarative path for node OS configuration;
ad-hoc SSH changes create drift and are not the intended operating model. And SNO -
single-node OpenShift - puts a whole cluster on one box at the edge. At fleet scale the labels
from Course VI decide which of these boxes runs what.</p>
<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 wraps the same core you already know; what
OpenShift adds is admission, routing and lifecycle opinions on top of it.</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.</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.
Argo CD applies the same habit to delivery. 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 <span class="mono">git log</span>; code review <i>is</i>
change control. Git records <i>intent</i> - the cluster's own audit log and Argo CD's sync
history record what actually happened, including what Git does not see.</p>
<p class="sayit">oc reads the running system; Git records the change.</p>
<h3>Pull, not push - the security inversion</h3>
<p>Here the cluster pulls. Push-based delivery exists and is still GitOps to many; the
pull variant is worth choosing deliberately, because no CI system, laptop or build pipeline
holds a credential that can touch the cluster - 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. With self-heal enabled,
Argo CD can restore the desired state; with prune enabled, objects removed from Git can
be deleted. Both are opt-in: without them the reconciler reports the drift and waits for a
sync. 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> With self-heal enabled, a live hand-patch may be
reverted on the next reconciliation. Without self-heal, Argo CD reports the drift until someone
syncs it. Either way, land the change in Git in the same hour, or the fix and the reason for it
go missing.</p>
<h3>At fleet scale, the label is the deploy button</h3>
<p>Run many OpenShift clusters under a hub - RHACM, Red Hat's fleet manager, with edge clusters
arriving through zero-touch provisioning, and nobody applies apps to clusters by hand. Each app carries a
Placement that selects cluster labels; 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. Labelling a
cluster changes its placement eligibility; the lifecycle and cleanup of what lands still follow
the configured propagation and deletion policy, so removing a label does not, on its own,
guarantee an app is deleted.</p>
<p class="sayit">Label the cluster; the app follows.</p>
<h3>Keep plaintext secret values out of Git</h3>
<p>In this architecture, plaintext secret values do not live in Git: Git stores the
ExternalSecret reference, and the vault stores the value. Commit a plaintext secret and you
should treat it as compromised from that moment - deleting it later does not guarantee
it is gone from history, forks, clones, CI caches or backups. So Git carries an ExternalSecret
naming a logical key; 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, not as a commit, which is why the vault sits apart from the pipeline in the scene
above.</p>
<p class="sayit">Git holds the shape of the secret. The vault holds the secret.</p>
<p class="closeline">Delivery becomes a property rather than an event: the cluster converges on
what the repository says. With protected branches and attributable identities, changes should
be traceable to reviewed commits.</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 reference anyone with push rights can move from one image to another. Registries may
audit tag updates, but the tag itself carries no immutability guarantee. The digest is the
sha256 of the image <i>manifest</i> (the small JSON index listing an image's layers, unrelated
to the YAML manifests you apply to a cluster). It identifies the exact uploaded content.
A local image ID printed by a build is not necessarily the same value, so pin the registry
digest that consumers actually pull.</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 label someone maintains, 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 a single
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
mirror: a pull-through cache like zot. On a cache miss the mirror fetches the artefact
from upstream; subsequent requests can be served locally while that content stays cached. Rate
limits, egress cost, disconnected sites,
control - one place to gate and audit what enters. OpenShift formalises the re-route with
image mirror rules, and carries a sharp edge: digest-mirror rules rewrite digest pulls
only, so a by-tag pull silently skips them - unless you also add an
ImageTagMirrorSet, 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 central mirror in the cloud fronts
upstream, and each 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, rather than every site hitting upstream at the same moment.
With a site configured to consume only its local mirror, its node image traffic stays on the
local network.</p>
<p class="sayit">Tiered mirrors fan the load out in layers rather than all at once.</p>
<h3>Build once, promote by copy</h3>
<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 once, then promote the same digest through environments by copying, registry to
registry - dev proves the exact bytes prod will run. For a multi-architecture image,
<span class="mono">skopeo copy --all</span> copies the complete image list; add
<span class="mono">--preserve-digests</span> when promotion requires the destination to keep
the source digests, so the copy fails if that cannot be done rather than silently landing a
different digest. Human tags ride along; the digest is the through-line.</p>
<p class="sayit">If the digest changed, it is not a promotion - it is a new candidate.</p>
<p class="closeline">Names that can move are convenient right up until they move. Address
content by its digest and the supply chain rests on a hash anyone can check, not on trust that
a tag stayed put.</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">Stop copying YAML between clusters.<br><em>Ship the function instead.</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>You are writing a program, not YAML</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> commonly holds
named templates - reusable partials that manifests can <span class="mono">include</span>
, rather than ordinary functions invoked directly. 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 rather
than what went in.</p>
<p class="sayit">Review the render, not just the template.</p>
<p class="fieldnote"><b>Field note.</b> Argo CD renders charts with
<span class="mono">helm template</span> rather than running <span class="mono">helm
install</span>, so the lifecycle differs from Helm's own. <span class="mono">lookup</span>
comes back empty, because 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: one small file per cluster</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 several rendered artefacts: the app config, network
attachment and two policies can all render from the same field, so those copies cannot diverge
- the lived version is <a href="https://www.bztmon.com/blog/one-value-many-enforcers/">on the blog</a>.</p>
<p class="sayit">The chart owns the shape. The context owns the numbers.</p>
<h3>Make the template refuse</h3>
<p>A template that renders whatever it is given just moves the failure downstream. A production-grade
chart carries a <span class="mono">values.schema.json</span>: a context missing a required
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">Consistency 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 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> existing workloads keep running. Repository-backed
refreshes and new changes become unavailable; what the controller can still evaluate depends
on state already available to it locally. Do not design around an assumed cache lifetime.
What stops is <i>change</i>; the running system is unaffected.</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-materialised Kubernetes Secrets typically
remain usable while the vault is unavailable. What fails is refresh, rotation and the
creation of new secret material - 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: a pod whose image is already in the node's store
can still start under <span class="mono">IfNotPresent</span>, but
<span class="mono">imagePullPolicy: Always</span> must resolve the reference against the
registry on each launch, so a mirror outage stops it even when the layers are present.</p>
</article>
</div>
<div class="prose">
<p class="closeline">None of these outages stop what is already running - they stop change,
rotation and recovery. Cache what you pull, keep secret values in a vault rather than Git, and
the cluster can hold its last applied state while a dependency is away.</p>
<p class="reads"><b>Further:</b> <a href="https://docs.github.com/">GitHub docs</a> &middot;
<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, Argo CD
renders and diffs it, and syncs the cluster - the cluster pulls rather than being pushed to.
Shape lane: the Helm chart plus a per-cluster values context passes the schema gate
and renders the manifests Argo CD applies. Artefact lane: CI builds once, pushes to
Azure Container Registry, the zot mirror caches it, and the node pulls by digest.
Secret lane: Azure Key Vault holds the values, the External Secrets operator syncs
them in - Git holds only 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 controllers that keep the 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 - in the pull model, the cluster fetches its own desired state
rather than being pushed to.</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 each push - in this flow it makes
artefacts and proposes changes, and does not hold a credential that can touch a cluster.</span>
<a href="https://docs.github.com/en/actions">docs.github.com/actions</a></li>
<li><b>Azure Key Vault</b><span>The managed vault holding secret values, so git carries only
references and rotation does not have to become a commit.</span>
<a href="https://learn.microsoft.com/azure/key-vault/">learn.microsoft.com/key-vault</a></li>
<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;
--dur-fast:.2s; --dur-base:.35s; --dur-slow:.6s; /* house-motion-system tokens */
--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)}
:focus-visible{outline:2px solid var(--cyan);outline-offset:3px;border-radius:2px}
.skip{position:absolute;left:-999px;top:0;z-index:100;background:var(--void);color:var(--cyan);
font-family:var(--mono);font-size:.8rem;padding:10px 16px;border:1px solid var(--cyan);border-radius:6px}
.skip:focus{left:12px;top:12px}
.purpose h2.kt{margin:0 0 1rem}
b{color:var(--ink)} .mono{font-family:var(--mono);font-size:.9em;color:var(--cyan)}
.masthead{position:relative;max-width:64ch;margin:0 auto;padding:16vh 24px 9vh;text-align:center}
/* The archipelago is anchored BELOW the text block: the words never sit over machinery.
Top edge starts under the lede; the mask feathers both ends so it dissolves into the void. */
.introbg{position:absolute;inset:52% -50vw -2vh;z-index:-1;overflow:hidden;pointer-events:none}
.introbg img{width:100%;height:100%;object-fit:cover;object-position:center 62%;opacity:.55;
-webkit-mask-image:linear-gradient(180deg,transparent 0%,#000 26%,#000 82%,transparent 100%);
mask-image:linear-gradient(180deg,transparent 0%,#000 26%,#000 82%,transparent 100%)}
@media (max-width:900px){.introbg{inset:58% -60vw -2vh}.introbg img{opacity:.4}}
.masthead > :not(.introbg){position:relative}
.chapnav{font-family:var(--mono);font-size:.72rem;letter-spacing:.14em;margin:0 0 2.2rem}
.chapnav a{color:var(--dim);text-decoration:none;border-bottom:1px solid transparent;
padding-bottom:2px;transition:color var(--dur-fast) ease,border-color var(--dur-fast) ease}
.chapnav a:hover,.chapnav a:focus-visible{color:var(--cyan);border-color:var(--cyan)}
.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;
transition:opacity var(--dur-base) ease}
.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);cursor:default;
opacity:.34;transition:opacity var(--dur-base) ease,border-color var(--dur-base) ease}
.legend li.lit{opacity:.72}
.legend li.act{opacity:1;border-left-color:var(--cyan)}
.legend li:hover,.legend li:focus-visible{opacity:1}
.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 var(--dur-base) ease,transform var(--dur-base) 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 .aside{font-size:.88rem !important;border-left:2px solid var(--line);padding-left:18px;
color:var(--dim) !important}
.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}
@media (prefers-reduced-motion: reduce){
html{scroll-behavior:auto}
*,*::before,*::after{animation-duration:.001ms !important;animation-iteration-count:1 !important;
transition-duration:.001ms !important;scroll-behavior:auto !important}
}
</style>
<script>
(() => {
"use strict";
// House motion tokens (skill: house-motion-system). Durations in seconds; nothing inline below.
const TOK = {
DUR: { fast: 0.2, base: 0.35, slow: 0.6, epic: 1.6 },
EASE: { enter: "power3.out", move: "power2.inOut", hover: "power1.out", scrub: "none" },
STAGGER: { tight: 0.03, base: 0.06, loose: 0.12 },
SCRUB: 0.6
};
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");
// legend text comes from repo JSON; build with DOM nodes + textContent, never innerHTML
if (legend) man.parts.forEach((p, i) => {
if (!p.title && !p.blurb) return;
const li = document.createElement("li");
li.dataset.part = String(i);
li.tabIndex = 0; // keyboard users get the same link as the mouse
const t = document.createElement("p"); t.className = "t"; t.textContent = p.title;
const b = document.createElement("p"); b.className = "b"; b.textContent = p.blurb;
li.append(t, b); legend.appendChild(li);
});
units.push({ sec, man, scene, flat, legend });
});
// ---- intro (class: entrance) --------------------------------------------------------
// Staged masthead reveal. from() sets the start state at runtime, so with JS off the
// masthead is simply visible - no opacity:0 baked into CSS.
if (window.gsap) {
const intro = [...document.querySelectorAll(
".masthead .eyebrow, .masthead h1, .masthead .lede, .masthead .scrollcue")];
if (intro.length) {
if (RM) {
// reduced: opacity only, fast, no movement
gsap.from(intro, { opacity: 0, duration: TOK.DUR.fast, ease: TOK.EASE.enter });
} else {
gsap.from(intro, {
opacity: 0, y: 20, duration: TOK.DUR.slow, ease: TOK.EASE.enter,
stagger: TOK.STAGGER.loose
});
// the rule under the scroll cue draws itself once the words have landed
gsap.from(".masthead .scrollcue span", {
scaleY: 0, transformOrigin: "top center", duration: TOK.DUR.slow,
ease: TOK.EASE.move, delay: TOK.STAGGER.loose * 4
});
}
}
}
// ---- intro backdrop (class: parallax) ------------------------------------------------
// Drifts up and fades as the index arrives. Parallax is disabled outright under reduced
// motion (house rule), which is why this sits after the RM check for its own animation.
const introBg = document.querySelector(".introbg img");
if (introBg && !RM && window.gsap && window.ScrollTrigger) {
gsap.to(introBg, {
yPercent: -12, opacity: 0.12, ease: TOK.EASE.scrub,
scrollTrigger: { trigger: ".masthead", start: "top top", end: "bottom top",
scrub: TOK.SCRUB }
});
}
// Reduced motion / no GSAP: leave the flat exploded hero in place (it IS the end state).
if (RM || !window.gsap || !window.ScrollTrigger) return;
document.body.classList.add("fx");
gsap.registerPlugin(ScrollTrigger);
const wc = (u, on) => u.slabs.forEach(el => el.style.willChange = on ? "transform" : "auto");
// Build every scene's slabs up front so pinned ScrollTriggers reserve their pin-spacing in the
// document flow from the start. Creating pins lazily reflows the page and floats a scene over the
// following section. The flat hero stays the fallback until its slabs exist.
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;
// per-scene motion signature (occlusion-safe scenes only; default is straight translate).
// "away" is 0 at the hero layout, 1 at collapsed/flown - every signature returns to identity
// at the hero so the assembled slices always match the source image exactly.
const M = u.man.motion, RAD = Math.PI / 180;
u.apply = t => {
const sg = u.man.stagger || 0, k = u.man.parts.length;
u.man.parts.forEach((p, i) => {
const pt = sg ? Math.min(1, Math.max(0, t * (1 + sg * (k - 1)) - sg * i)) : t;
const e = u.man.reverse ? (1 - pt) : pt;
const away = 1 - e;
let ox = p.dx * away, oy = p.dy * away, extra = "";
if (M === "tumble") { // aperitif: fragments spin as they fly out
const spin = (p.spin != null ? p.spin : (i % 2 ? 20 : -20)) * away;
extra = " rotate(" + spin.toFixed(2) + "deg)";
} else if (M === "arc") { // traffic: slices travel a bowed (curved) path
const len = Math.hypot(p.dx, p.dy) || 1;
const bow = (u.man.bow || 9) * 4 * away * (1 - away);
ox += (-p.dy / len) * bow; oy += (p.dx / len) * bow;
} else if (M === "orbit") { // OpenShift: satellites spiral in around the core
const a = (u.man.orbit || 55) * away * RAD, c = Math.cos(a), sn = Math.sin(a);
ox = (p.dx * c - p.dy * sn) * away; oy = (p.dx * sn + p.dy * c) * away;
}
u.slabs[i].style.transform =
"translate3d(" + ox.toFixed(3) + "%," + oy.toFixed(3) + "%,0)" + extra;
});
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
// Object <-> text linkage: the row being read is the part that stands out. The scrub
// walks the list top to bottom; whichever row is current gets .act, its slab stays at
// full strength and the rest sit back. Reading position and visual focus never diverge.
if (n) {
let act = -1;
lis.forEach((li, i) => {
const on = t >= 0.9 * ((i + 0.5) / n);
li.classList.toggle("lit", on);
if (on) act = i;
});
u.act = act;
if (u.pin == null) u.paint();
}
};
// paint() applies the focus state: pinned (hover/focus) wins over the scroll position.
u.paint = () => {
const f = u.pin != null ? u.pin : u.act;
u.slabs.forEach((el, i) => { el.style.opacity = (f < 0 || i === f) ? "1" : "0.38"; });
lis.forEach((li, i) => li.classList.toggle("act", i === f));
};
u.pin = null; u.act = -1;
// hover or keyboard focus on a row pins the link to that part - user-initiated, so it
// stays available even when the scrub is not moving.
lis.forEach((li, i) => {
const on = () => { u.pin = i; u.paint(); };
const off = () => { u.pin = null; u.paint(); };
li.addEventListener("mouseenter", on);
li.addEventListener("focus", on);
li.addEventListener("mouseleave", off);
li.addEventListener("blur", off);
});
});
const boot = () => {
const desktop = document.documentElement.scrollHeight - innerHeight > 200 &&
matchMedia("(min-width: 901px)").matches;
units.forEach(u => {
if (desktop) {
u.mode = "pin+scrub";
ScrollTrigger.create({
trigger: u.sec.querySelector(".pin"),
start: "top top", end: "+=1800", pin: true, scrub: TOK.SCRUB,
onToggle: self => wc(u, self.isActive),
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);
// assemble once, when the scene scrolls in; keep will-change only for the tween's life
ScrollTrigger.create({
trigger: u.scene, start: "top 80%", once: true,
onEnter: () => { wc(u, true); gsap.to({ v: 0 }, {
v: 1, duration: TOK.DUR.epic, ease: TOK.EASE.enter,
onUpdate: function () { u.apply(this.targets()[0].v); },
onComplete: () => wc(u, false) }); }
});
}
});
ScrollTrigger.refresh();
};
// the aperitif terminal types its transcript once, when it first enters view
document.querySelectorAll(".crt").forEach(crt => {
const lines = [...crt.querySelectorAll(".line")];
const io = new IntersectionObserver(es => {
if (!es.some(x => x.isIntersecting)) return;
io.disconnect();
lines.forEach((l, i) => setTimeout(() => l.classList.add("on"),
TOK.DUR.base * 1000 * i + TOK.DUR.fast * 1000));
}, { threshold: 0.4 });
io.observe(crt);
});
// boot on the next frame; do not gate on decoding lazy images (that stalls with loading=lazy)
requestAnimationFrame(() => requestAnimationFrame(boot));
})();
</script>