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

434 lines
25 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">Three courses on the toolchain around the cluster: 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>
<!-- ============ 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 GitOps nothing is pushed. A repository holds the desired state, an agent
inside the cluster watches it, and the cluster pulls its own future from git. kubectl is for
looking; git is for changing.</p>
</header>
<div class="pin">
<div class="viewport">
<figure class="scene">
<img class="flat" src="__HERO_GITOPS__"
alt="An exploded chain: an etched repository crystal, a twin-ring reconciler engine,
a stack of rendered manifests and a cluster slab - with a drift shard falling
away and an armoured secret vault floating deliberately apart.">
</figure>
<ol class="legend"></ol>
</div>
<div class="bench"></div>
</div>
<div class="prose">
<h3>The loop you already know, one level up</h3>
<p>Course II taught the reconciliation loop: desired versus actual, fix the difference, repeat.
ArgoCD is the same habit applied to <b>delivery</b>. An Application names a repo, a path and a
revision - watch this branch of this repository - and the controller renders what it finds
there, compares it against the live cluster, and syncs the difference. The deploy button is a
git commit; the audit log is git log; code review <i>is</i> change control.</p>
<p class="sayit">kubectl apply is for archaeology, not for change.</p>
<h3>Pull, not push - the security inversion</h3>
<p>The cluster <b>pulls</b>. No CI system, no laptop, no build pipeline holds a credential that
can touch it - the agent inside holds a read-only deploy key, and the trust arrow points out.
Compromise the build system and you can propose a change, which is visible; you cannot reach
into production. Hand-edit a live object and the controller flags it OutOfSync - selfHeal puts
it back, and what leaves git leaves the cluster. Rollback is <span class="mono">git revert</span>,
which is why commit hygiene is an operational skill.</p>
<p class="fieldnote"><b>Field note.</b> Fighting the reconciler over SSH is arm-wrestling a
machine that does not get tired - Course II's warning, now with a face. The fix is always
upstream, in git.</p>
<h3>The one thing git never holds</h3>
<p>Git holds everything except secrets - a secret in git is published, forever, to everyone who
ever clones. So the pattern splits the reference from the value: git carries an ExternalSecret
naming a <b>logical key</b>; a vault - Azure Key Vault in the worked example - holds the value;
an operator inside the cluster exchanges one for the other at runtime. Rotation happens in the
vault, never as a commit. That is why the vault floats apart in the scene above: it is never
absorbed into the pipeline.</p>
<p class="sayit">Git holds the shape of the secret. The vault holds the secret.</p>
<p class="closeline">Delivery stops being an event and becomes a property: the cluster is always
converging on what the repository says. "Who deployed this?" becomes "who merged this?" - and
that question always has an answer.</p>
<p class="reads"><b>Pre-reads:</b> Course 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 is the image's actual name: same bytes,
same digest, forever.</p>
</header>
<div class="pin">
<div class="viewport">
<figure class="scene">
<img class="flat" src="__HERO_SUPPLY__"
alt="The image journey: a layered image stack, an upstream registry tower, a squat
pull-through mirror and a node core - beneath a ghost tag plate and an engraved
digest seal floating side by side.">
</figure>
<ol class="legend"></ol>
</div>
<div class="bench"></div>
</div>
<div class="prose">
<h3>Say the name properly</h3>
<p>Three ways to name an image, in rising order of honesty: <span class="mono">:latest</span>
(a moving target), <span class="mono">:1.4.2</span> (a promise somebody keeps, until they
re-push it), and <span class="mono">name:1.4.2@sha256:...</span> - a fact. The tag stays for
human eyes; the digest does the pulling. Pin by digest and "what is running?" has exactly one
answer.</p>
<p class="fieldnote"><b>Field note.</b> <span class="mono">:latest</span> is how two nodes run
different code from one manifest - the second node pulled an hour later, after a re-push.
Nobody changed the YAML.</p>
<h3>Why a fleet pulls once</h3>
<p>Between the build and the node sits the registry chain. Upstream, a managed registry - Azure
Container Registry in the worked example - holds what CI built. In front of the cluster sits a
<b>mirror</b>: a pull-through cache like zot. The fleet asks the mirror, the mirror asks
upstream once, everything after is local. Rate limits, egress cost, disconnected sites,
control - one place to gate and audit what enters. OpenShift formalises the re-route with
image mirror rules, and carries a sharp edge: digest-mirror rules rewrite <b>digest pulls
only</b>, so a by-tag pull silently skips the mirror. The pinning habit is what makes the
mirror actually catch the traffic.</p>
<h3>Build once, promote by copy</h3>
<p>Every rebuild is a different artefact - different digest, untested by the stages before it.
So build <b>once</b>, then promote the same digest through environments by copying, registry
to registry - dev proves the exact bytes prod will run. Human tags ride along; the digest is
the through-line.</p>
<p class="sayit">If the digest changed, it is not a promotion - it is a new candidate.</p>
<p class="closeline">Names that can move are convenient exactly until they move. Address content
by what it is, and the supply chain stops being a chain of trust and becomes a chain of
arithmetic.</p>
<p class="reads"><b>Pre-reads:</b> Course 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 class="bench"></div>
</div>
<div class="prose">
<h3>It's Go under the braces</h3>
<p>Helm templates are Go text/template: <span class="mono">{{ .Values.device.address }}</span>
is a pipeline walking a values object, <span class="mono">_helpers.tpl</span> holds the named
functions every manifest calls. You are not writing YAML - you are writing a program whose
output is YAML. So render locally, read the output, and lint what came out, not what went in.</p>
<p class="sayit">Review the render, not just the template.</p>
<p class="fieldnote"><b>Field note.</b> ArgoCD deploys charts by running
<span class="mono">helm template</span> - a pure render. Anything needing a live cluster or an
install lifecycle (<span class="mono">lookup</span>, hooks) is silently dead there. Render the
way your deployer renders.</p>
<h3>Contexts: the cluster's whole voice is one small file</h3>
<p>The chart owns everything structural - resources, probes, security, policy. Each cluster owns
one values file: names, addresses, sizes, flags. The context is deliberately values-only; the
moment it carries its own manifests there are two owners for one object, and they will
disagree. One value can feed many rendered artefacts - an address appearing in the app config,
the network attachment and two policies renders from one field, so the copies cannot diverge.</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> Course 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> the cluster does not care - the reconciler enforces
the last synced state indefinitely. What stops is <i>change</i>. GitOps degrades to
read-only, which is the graceful half of the design.</p>
</article>
<article class="card">
<h3>Azure Key Vault</h3>
<p class="what">Where the secret values live - git carries the reference, the vault carries
the value, an operator keeps them synced.</p>
<p class="leans"><b>Leans on it:</b> secret sync, rotation, the first deploy of anything that
needs a credential.</p>
<p class="down"><b>When it is down:</b> already-synced Secrets keep working - values are
materialised in-cluster. What stops is <i>rotation</i> and new secrets. Survivable - unless
you are inside a rotation window.</p>
</article>
<article class="card">
<h3>zot</h3>
<p class="what">Where the fleet pulls from - a pull-through mirror between the cluster and the
internet, and the control point for what enters.</p>
<p class="leans"><b>Leans on it:</b> every image pull on every node - boot, reschedule,
scale-up, recovery.</p>
<p class="down"><b>When it is down:</b> the sharpest edge. Upstream down + mirror up = nobody
notices. Mirror down on a mirror-only pull path = nothing new schedules, and a rebooting
node may not come back whole.</p>
</article>
</div>
<div class="prose">
<p class="closeline">None of these outages stop what is already running - they stop change,
rotation and recovery, in that order of pain. Cache what you pull, split references from
values, and let the cluster hold its last known truth without asking anyone's permission.</p>
<p class="reads"><b>Further:</b> <a href="https://docs.github.com/">GitHub docs</a> &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>
<footer class="outro">
<p>The Exploded Cluster &middot; the delivery arc &middot; every scene is one generated hero,
sliced and scrubbed - no diagrams were harmed.</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)}
.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}
.bench{width:min(720px,100%);margin:16px auto 0;text-align:center}
.bench input{width:100%;accent-color:var(--cyan)}
.bench .hud{font-family:var(--mono);font-size:.66rem;letter-spacing:.06em;color:var(--dim);margin:.45rem 0 0}
.prose{max-width:66ch;margin:0 auto;padding:5vh 24px 9vh}
.prose h3{font-size:1.18rem;font-weight:600;letter-spacing:-.01em;margin:2.6rem 0 .8rem;color:var(--ink)}
.prose p{font-size:.98rem;line-height:1.75;color:var(--dim);margin:0 0 1.1rem}
.sayit{font-weight:600;color:var(--cyan) !important;border-left:2px solid var(--cyan);padding-left:18px}
.fieldnote{border-left:2px solid var(--amber);padding-left:18px;font-size:.92rem !important}
.fieldnote b{color:var(--amber);font-family:var(--mono);font-size:.7rem;letter-spacing:.16em;
text-transform:uppercase;display:block;margin-bottom:.35rem}
.closeline{color:var(--ink) !important;margin-top:2.2rem !important}
.reads{font-size:.8rem !important;line-height:1.9 !important;border-top:1px solid var(--line);
padding-top:1.2rem;margin-top:2.4rem !important}
.reads b{font-family:var(--mono);font-size:.68rem;letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
.appendix .still{max-width:900px;margin:2vh auto 0;padding:0 24px}
.appendix .still img{width:100%;height:auto;display:block}
.appendix .still figcaption{font-family:var(--mono);font-size:.7rem;letter-spacing:.14em;
text-transform:uppercase;color:var(--dim);text-align:center;margin-top:.9rem}
.cards{max-width:1100px;margin:5vh auto 0;padding:0 24px;display:grid;
grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}
.card{border:1px solid var(--line);border-radius:10px;padding:22px 22px 18px;background:#0a1020}
.card h3{margin:0 0 .7rem;font-size:1.05rem;letter-spacing:-.01em}
.card p{font-size:.85rem;line-height:1.65;color:var(--dim);margin:0 0 .9rem}
.card .leans{border-left:2px solid var(--cyan);padding-left:14px}
.card .down{border-left:2px solid var(--amber);padding-left:14px;margin-bottom:0}
.card .leans b,.card .down b{font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;
text-transform:uppercase;display:block;margin-bottom:.3rem}
.card .leans b{color:var(--cyan)} .card .down b{color:var(--amber)} .card i{color:var(--ink);font-style:normal;font-weight:600}
.outro{max-width:64ch;margin:0 auto;padding:6vh 24px 14vh;text-align:center}
.outro p{font-family:var(--mono);font-size:.7rem;letter-spacing:.12em;color:var(--dim);line-height:1.9}
@media (max-width:900px){
.viewport{grid-template-columns:1fr;gap:20px}
.pin{min-height:0;padding:3vh 18px 5vh}
.masthead{padding:9vh 22px 6vh}
}
body:not(.fx) .slab{display:none}
body:not(.fx) .bench{display:none}
</style>
<script>
(() => {
"use strict";
const SCENES = { 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");
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 dists = u.man.parts.map(p => Math.hypot(p.dx, p.dy));
const maxD = Math.max(...dists, 1e-6);
const lis = [...u.legend.children];
u.apply = t => {
u.man.parts.forEach((p, i) => {
u.slabs[i].style.transform =
"translate3d(" + (p.dx * (1 - t)).toFixed(3) + "%," + (p.dy * (1 - t)).toFixed(3) + "%,0)";
});
u.scene.style.transform = "scale(" + (0.95 + 0.05 * t).toFixed(4) + ")";
lis.forEach((li, i) => li.classList.toggle("lit", t >= 0.82 * (dists[i] / maxD)));
};
// bench: hand scrub + HUD (pilot instrument - strip for the site ship)
const bench = u.sec.querySelector(".bench");
bench.innerHTML = '<input type="range" min="0" max="1000" value="0" aria-label="scrub the teardown">' +
'<p class="hud">boot</p>';
u.hud = bench.querySelector(".hud");
u.slider = bench.querySelector("input");
u.show = v => {
u.hud.textContent = u.mode + " · " + innerWidth + "x" + innerHeight +
" · scroll " + Math.round(scrollY) + " · t=" + v.toFixed(2);
u.slider.value = String(Math.round(v * 1000));
};
u.drive = v => { u.apply(v); u.show(v); };
u.slider.addEventListener("input", () => u.drive(+u.slider.value / 1000));
});
const boot = () => {
const scrollable = document.documentElement.scrollHeight - innerHeight > 200;
units.forEach(u => {
if (RM) { u.mode = "reduced-motion"; u.drive(1); return; }
if (scrollable && matchMedia("(min-width: 901px)").matches) {
u.mode = "pin+scrub";
ScrollTrigger.create({
trigger: u.sec.querySelector(".pin"),
start: "top top",
end: "+=1800",
pin: true,
scrub: 0.6,
onUpdate: self => u.drive(self.progress),
onRefreshInit: () => u.apply(0)
});
u.drive(0);
} else {
u.mode = "assemble";
u.sec.querySelector(".pin").style.minHeight = "0";
u.drive(0);
const io = new IntersectionObserver(es => {
if (!es.some(e => e.isIntersecting)) return;
io.disconnect();
gsap.to({ v: 0 }, { v: 1, duration: 1.6, ease: "power3.out",
onUpdate: function () { u.drive(this.targets()[0].v); } });
}, { threshold: 0.2 });
io.observe(u.scene);
}
});
if (!RM) ScrollTrigger.refresh();
};
Promise.all(units.map(u => u.flat.decode ? u.flat.decode().catch(() => {}) : Promise.resolve()))
.then(() => requestAnimationFrame(() => requestAnimationFrame(boot)));
})();
</script>