diff --git a/.gitignore b/.gitignore index 52116f9..cef8cda 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ assets/work/ dist/ pilot/course-1.html +pilot/arc.html diff --git a/pilot/arc.tpl.html b/pilot/arc.tpl.html new file mode 100644 index 0000000..5685b65 --- /dev/null +++ b/pilot/arc.tpl.html @@ -0,0 +1,432 @@ +
+

The Exploded Cluster · The Delivery Arc

+

The machine was the easy part.
Now watch how software reaches it.

+

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.

+ +
+ + +
+
+

Course VI · GitOps

+

Nobody deploys anything.
The cluster syncs itself.

+

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.

+
+
+
+
+ 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. +
+
    +
    +
    +
    +
    +

    The loop you already know, one level up

    +

    Course II taught the reconciliation loop: desired versus actual, fix the difference, repeat. + ArgoCD is the same habit applied 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 audit log is git log; code review is change control.

    +

    kubectl apply is for archaeology, not for change.

    +

    Pull, not push - the security inversion

    +

    The cluster pulls. 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 git revert, + which is why commit hygiene is an operational skill.

    +

    Field note. 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.

    +

    The one thing git never holds

    +

    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 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, never as a commit. That is why the vault floats apart in the scene above: it is never + absorbed into the pipeline.

    +

    Git holds the shape of the secret. The vault holds the secret.

    +

    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.

    +

    Pre-reads: Course II · + Kubernetes concepts · + git + pull requests +  Further: Argo CD · + OpenShift GitOps · + External Secrets · + Azure Key Vault

    +
    +
    + + +
    +
    +

    Course VII · The image supply chain

    +

    A tag is a promise.
    A digest is a fact.

    +

    myapp:latest 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.

    +
    +
    +
    +
    + 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. +
    +
      +
      +
      +
      +
      +

      Say the name properly

      +

      Three ways to name an image, in rising order of honesty: :latest + (a moving target), :1.4.2 (a promise somebody keeps, until they + re-push it), and name:1.4.2@sha256:... - a fact. The tag stays for + human eyes; the digest does the pulling. Pin by digest and "what is running?" has exactly one + answer.

      +

      Field note. :latest 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.

      +

      Why a fleet pulls once

      +

      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. 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 digest pulls + only, so a by-tag pull silently skips the mirror. The pinning habit is what makes the + mirror actually catch the traffic.

      +

      Build once, promote by copy

      +

      Every rebuild is a different artefact - different digest, 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. Human tags ride along; the digest is + the through-line.

      +

      If the digest changed, it is not a promotion - it is a new candidate.

      +

      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.

      +

      Pre-reads: Course I · + Kubernetes images +  Further: zot · + Azure Container Registry · + OpenShift image mirroring · + skopeo

      +
      +
      + + +
      +
      +

      Course VIII · Helm

      +

      A chart is a function.
      The values file is the cluster speaking.

      +

      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.

      +
      +
      +
      +
      + 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. +
      +
        +
        +
        +
        +
        +

        It's Go under the braces

        +

        Helm templates are Go text/template: {{ .Values.device.address }} + is a pipeline walking a values object, _helpers.tpl 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.

        +

        Review the render, not just the template.

        +

        Field note. ArgoCD deploys charts by running + helm template - a pure render. Anything needing a live cluster or an + install lifecycle (lookup, hooks) is silently dead there. Render the + way your deployer renders.

        +

        Contexts: the cluster's whole voice is one small file

        +

        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 chart owns the shape. The context owns the numbers.

        +

        Make the template refuse

        +

        A template that renders whatever it is given just moves the failure downstream. The grown-up + chart carries a values.schema.json: 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.

        +

        Field note. The failure you want is the render that refuses. It + costs a red pipeline. The alternative reports healthy the whole time.

        +

        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.

        +

        Pre-reads: Course II · + Kubernetes objects +  Further: Helm docs · + chart template guide · + Go text/template · + Helm on OpenShift

        +
        +
        + + +
        +
        +

        Appendix · The dependency ledger

        +

        Every toolchain stands on
        services it does not run.

        +

        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.

        +
        +
        + 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. +
        You have met these three before.
        +
        +
        +
        +

        GitHub

        +

        Where the desired state lives - the system of record the whole loop watches, + through a read-only deploy key.

        +

        Leans on it: sync, rollback, change review, the "who merged this" + answer.

        +

        When it is down: the cluster does not care - the reconciler enforces + the last synced state indefinitely. What stops is change. GitOps degrades to + read-only, which is the graceful half of the design.

        +
        +
        +

        Azure Key Vault

        +

        Where the secret values live - git carries the reference, the vault carries + the value, an operator keeps them synced.

        +

        Leans on it: secret sync, rotation, the first deploy of anything that + needs a credential.

        +

        When it is down: already-synced Secrets keep working - values are + materialised in-cluster. What stops is rotation and new secrets. Survivable - unless + you are inside a rotation window.

        +
        +
        +

        zot

        +

        Where the fleet pulls from - a pull-through mirror between the cluster and the + internet, and the control point for what enters.

        +

        Leans on it: every image pull on every node - boot, reschedule, + scale-up, recovery.

        +

        When it is down: 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.

        +
        +
        +
        +

        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.

        +

        Further: GitHub docs · + Azure Key Vault · + zot · + Kubernetes · + Helm · + Red Hat OpenShift

        +
        +
        + + + + + + diff --git a/pilot/assemble-arc.py b/pilot/assemble-arc.py new file mode 100644 index 0000000..1a1a450 --- /dev/null +++ b/pilot/assemble-arc.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Assemble the delivery-arc page: tpl + inlined GSAP + scene manifests + base64 heroes. + +Heroes are inlined ONCE (in the src); the slice engine reuses img.src for the +slab backgrounds, so nothing is duplicated. Manifests come from tools/slice-arc.py. +""" +import base64, json, pathlib, re + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +def b64(webp): + return "data:image/webp;base64," + base64.b64encode((ROOT / "assets/dist" / webp).read_bytes()).decode() + +def man(tag): + return json.dumps(json.loads((ROOT / "pilot" / f"manifest-{tag}.json").read_text()), + separators=(",", ":")) + +out = (ROOT / "pilot/arc.tpl.html").read_text() \ + .replace("__MAN_GITOPS__", man("gitops")) \ + .replace("__MAN_SUPPLY__", man("supply")) \ + .replace("__MAN_HELM__", man("helm")) \ + .replace("__HERO_GITOPS__", b64("course-VI-gitops.webp")) \ + .replace("__HERO_SUPPLY__", b64("course-VII-supply-chain.webp")) \ + .replace("__HERO_HELM__", b64("course-VIII-helm-press.webp")) \ + .replace("__HERO_APPENDIX__", b64("appendix-dependency-ledger.webp")) + +assert "__MAN_" not in out and "__HERO_" not in out, "unfilled placeholder" + +out = (f"\n" + f"\n" + out) + +dest = ROOT / "pilot/arc.html" +dest.write_text(out) +heroes = len(re.findall(r"data:image/webp", out)) +print(f"built pilot/arc.html {len(out)/1024:.0f}KB ({heroes} heroes inlined)") diff --git a/pilot/manifest-gitops.json b/pilot/manifest-gitops.json new file mode 100644 index 0000000..344c757 --- /dev/null +++ b/pilot/manifest-gitops.json @@ -0,0 +1,768 @@ +{ + "scene": "gitops", + "source": "assets/raw/course-VI-gitops.jpeg", + "parts": [ + { + "name": "repo", + "title": "The repository", + "blurb": "Desired state lives here. The deploy button is a git commit; the audit log is git log.", + "points": [ + [ + 13.7, + 36.46 + ], + [ + 13.26, + 35.57 + ], + [ + 13.19, + 33.7 + ], + [ + 13.19, + 32.55 + ], + [ + 13.26, + 29.68 + ], + [ + 13.84, + 28.52 + ], + [ + 14.35, + 27.94 + ], + [ + 15.66, + 26.44 + ], + [ + 16.46, + 25.51 + ], + [ + 23.36, + 17.9 + ], + [ + 26.79, + 15.17 + ], + [ + 28.04, + 14.13 + ], + [ + 31.24, + 14.13 + ], + [ + 33.21, + 15.56 + ], + [ + 35.14, + 17.83 + ], + [ + 36.52, + 19.54 + ], + [ + 37.46, + 20.66 + ], + [ + 39.06, + 22.51 + ], + [ + 39.57, + 23.54 + ], + [ + 39.64, + 25.68 + ], + [ + 39.64, + 29.39 + ], + [ + 39.06, + 30.54 + ], + [ + 38.7, + 30.97 + ], + [ + 38.19, + 31.56 + ], + [ + 35.94, + 34.12 + ], + [ + 26.02, + 44.21 + ], + [ + 25.17, + 44.86 + ], + [ + 24.92, + 44.99 + ], + [ + 21.96, + 44.99 + ], + [ + 21.35, + 44.6 + ], + [ + 20.45, + 43.95 + ], + [ + 15.15, + 38.17 + ], + [ + 13.92, + 36.76 + ] + ], + "dx": 9.27, + "dy": 8.52 + }, + { + "name": "engine", + "title": "ArgoCD", + "blurb": "Watches repo + path + revision, compares desired against actual, and syncs the difference.", + "points": [ + [ + 48.29, + 31.4 + ], + [ + 48.29, + 43.47 + ], + [ + 48.22, + 43.78 + ], + [ + 47.93, + 44.69 + ], + [ + 47.35, + 45.94 + ], + [ + 47.13, + 46.27 + ], + [ + 45.46, + 48.59 + ], + [ + 39.41, + 54.23 + ], + [ + 35.64, + 57.1 + ], + [ + 35.3, + 57.1 + ], + [ + 31.94, + 54.49 + ], + [ + 31.29, + 53.97 + ], + [ + 30.63, + 52.4 + ], + [ + 30.56, + 52.08 + ], + [ + 30.05, + 45.02 + ], + [ + 30.05, + 43.21 + ], + [ + 30.12, + 42.46 + ], + [ + 30.27, + 41.25 + ], + [ + 30.41, + 40.49 + ], + [ + 30.78, + 38.96 + ], + [ + 31.07, + 38.03 + ], + [ + 31.29, + 37.39 + ], + [ + 32.3, + 35.06 + ], + [ + 32.74, + 34.32 + ], + [ + 39.51, + 27.93 + ], + [ + 39.95, + 27.67 + ], + [ + 43.5, + 25.98 + ], + [ + 43.84, + 25.98 + ], + [ + 45.48, + 27.15 + ], + [ + 46.84, + 28.71 + ], + [ + 47.35, + 29.44 + ], + [ + 47.71, + 29.96 + ], + [ + 48.22, + 31.09 + ] + ], + "dx": 0.0, + "dy": 0.0 + }, + { + "name": "sheets", + "title": "Rendered manifests", + "blurb": "What the repo's charts and manifests render to - build artefacts nobody hand-edits.", + "points": [ + [ + 39.57, + 59.81 + ], + [ + 39.57, + 52.61 + ], + [ + 40.08, + 51.59 + ], + [ + 40.59, + 51.01 + ], + [ + 42.04, + 49.36 + ], + [ + 47.79, + 42.25 + ], + [ + 51.84, + 39.13 + ], + [ + 54.79, + 36.65 + ], + [ + 54.96, + 36.52 + ], + [ + 55.04, + 36.52 + ], + [ + 56.61, + 37.69 + ], + [ + 61.59, + 42.77 + ], + [ + 64.35, + 45.97 + ], + [ + 64.57, + 46.27 + ], + [ + 65.01, + 47.15 + ], + [ + 65.01, + 54.33 + ], + [ + 64.57, + 55.2 + ], + [ + 64.35, + 55.49 + ], + [ + 51.02, + 69.08 + ], + [ + 49.95, + 69.99 + ], + [ + 49.79, + 70.12 + ], + [ + 49.54, + 70.12 + ], + [ + 48.69, + 69.47 + ], + [ + 41.24, + 62.12 + ], + [ + 40.01, + 60.7 + ] + ], + "dx": -9.34, + "dy": -8.61 + }, + { + "name": "cluster", + "title": "The cluster", + "blurb": "Actual state. It pulls its own future - no CI system holds a credential that can touch it.", + "points": [ + [ + 52.8, + 72.93 + ], + [ + 52.29, + 71.93 + ], + [ + 52.22, + 70.38 + ], + [ + 52.22, + 68.69 + ], + [ + 52.29, + 68.13 + ], + [ + 52.94, + 66.85 + ], + [ + 53.16, + 66.57 + ], + [ + 54.61, + 64.7 + ], + [ + 61.55, + 55.92 + ], + [ + 64.87, + 53.32 + ], + [ + 67.36, + 51.37 + ], + [ + 69.43, + 49.93 + ], + [ + 70.76, + 49.93 + ], + [ + 74.11, + 52.28 + ], + [ + 75.36, + 53.19 + ], + [ + 78.6, + 56.99 + ], + [ + 82.09, + 61.01 + ], + [ + 82.45, + 61.46 + ], + [ + 83.76, + 63.05 + ], + [ + 83.98, + 63.34 + ], + [ + 84.56, + 64.48 + ], + [ + 84.56, + 68.27 + ], + [ + 83.98, + 69.39 + ], + [ + 83.76, + 69.68 + ], + [ + 81.8, + 71.84 + ], + [ + 74.1, + 81.06 + ], + [ + 69.78, + 84.57 + ], + [ + 67.95, + 86.13 + ], + [ + 67.62, + 86.39 + ], + [ + 66.3, + 86.39 + ], + [ + 64.8, + 85.22 + ], + [ + 58.54, + 79.49 + ], + [ + 53.81, + 74.11 + ], + [ + 53.02, + 73.22 + ] + ], + "dx": -20.96, + "dy": -19.23 + }, + { + "name": "shard", + "title": "Drift", + "blurb": "A hand-edited live object. selfHeal puts it back; what leaves git leaves the cluster.", + "points": [ + [ + 80.04, + 94.73 + ], + [ + 77.39, + 94.21 + ], + [ + 76.89, + 94.08 + ], + [ + 76.58, + 93.95 + ], + [ + 76.37, + 93.82 + ], + [ + 75.51, + 92.91 + ], + [ + 74.82, + 91.82 + ], + [ + 72.35, + 82.43 + ], + [ + 72.35, + 82.26 + ], + [ + 72.42, + 81.76 + ], + [ + 72.93, + 80.47 + ], + [ + 73.15, + 80.05 + ], + [ + 73.51, + 79.38 + ], + [ + 75.28, + 77.67 + ], + [ + 75.49, + 77.54 + ], + [ + 75.58, + 77.54 + ], + [ + 79.69, + 78.32 + ], + [ + 80.2, + 78.45 + ], + [ + 81.18, + 79.49 + ], + [ + 81.63, + 80.01 + ], + [ + 82.01, + 81.62 + ], + [ + 83.9, + 88.5 + ], + [ + 84.05, + 88.98 + ], + [ + 84.05, + 89.31 + ], + [ + 83.98, + 89.65 + ], + [ + 83.39, + 91.11 + ], + [ + 83.18, + 91.52 + ], + [ + 80.34, + 94.6 + ], + [ + 80.14, + 94.73 + ] + ], + "dx": -27.99, + "dy": -32.13 + }, + { + "name": "vault", + "title": "The vault", + "blurb": "Azure Key Vault in the worked example. Git carries the reference; the vault carries the value. Never absorbed into the pipeline.", + "points": [ + [ + 56.07, + 10.57 + ], + [ + 56.07, + 23.29 + ], + [ + 55.63, + 24.38 + ], + [ + 55.42, + 24.79 + ], + [ + 55.05, + 25.44 + ], + [ + 51.4, + 28.32 + ], + [ + 50.13, + 29.23 + ], + [ + 49.87, + 29.23 + ], + [ + 49.33, + 28.84 + ], + [ + 45.24, + 25.98 + ], + [ + 44.51, + 24.92 + ], + [ + 44.29, + 24.5 + ], + [ + 43.86, + 23.37 + ], + [ + 43.86, + 10.48 + ], + [ + 44.29, + 9.35 + ], + [ + 44.51, + 8.92 + ], + [ + 44.9, + 8.27 + ], + [ + 48.89, + 5.4 + ], + [ + 49.78, + 4.75 + ], + [ + 50.3, + 4.75 + ], + [ + 50.66, + 5.01 + ], + [ + 55.05, + 8.39 + ], + [ + 55.56, + 9.28 + ] + ], + "dx": -2.34, + "dy": 5.37 + } + ] +} \ No newline at end of file diff --git a/pilot/manifest-helm.json b/pilot/manifest-helm.json new file mode 100644 index 0000000..7ca4e2e --- /dev/null +++ b/pilot/manifest-helm.json @@ -0,0 +1,466 @@ +{ + "scene": "helm", + "source": "assets/raw/course-VIII-helm-press.jpeg", + "parts": [ + { + "name": "press", + "title": "The chart", + "blurb": "A function with holes - the template owns every structural decision, and renders whatever fills it.", + "points": [ + [ + 28.09, + 44.03 + ], + [ + 28.23, + 43.34 + ], + [ + 28.81, + 42.23 + ], + [ + 41.58, + 28.45 + ], + [ + 47.7, + 22.85 + ], + [ + 49.1, + 21.55 + ], + [ + 49.41, + 21.29 + ], + [ + 49.88, + 21.29 + ], + [ + 50.11, + 21.42 + ], + [ + 60.78, + 31.05 + ], + [ + 69.66, + 40.45 + ], + [ + 70.02, + 40.89 + ], + [ + 70.6, + 42.04 + ], + [ + 70.68, + 42.6 + ], + [ + 71.4, + 68.03 + ], + [ + 71.4, + 69.14 + ], + [ + 71.33, + 69.42 + ], + [ + 70.89, + 70.27 + ], + [ + 70.02, + 71.43 + ], + [ + 65.08, + 78.29 + ], + [ + 60.99, + 82.75 + ], + [ + 58.16, + 85.74 + ], + [ + 57.76, + 86.13 + ], + [ + 57.37, + 86.26 + ], + [ + 56.27, + 86.26 + ], + [ + 55.5, + 85.61 + ], + [ + 53.39, + 83.66 + ], + [ + 44.95, + 76.11 + ], + [ + 42.79, + 74.29 + ], + [ + 41.68, + 73.03 + ], + [ + 41.46, + 72.69 + ], + [ + 28.74, + 53.35 + ], + [ + 28.31, + 52.53 + ], + [ + 28.23, + 52.26 + ], + [ + 28.09, + 44.72 + ] + ], + "dx": 0.0, + "dy": 0.0 + }, + { + "name": "contexts", + "title": "Values contexts", + "blurb": "One small file per cluster - names, addresses, sizes. The cluster's whole voice, values only.", + "points": [ + [ + 24.11, + 21.94 + ], + [ + 34.92, + 19.22 + ], + [ + 35.07, + 19.25 + ], + [ + 36.67, + 21.02 + ], + [ + 36.81, + 21.19 + ], + [ + 37.39, + 22.28 + ], + [ + 38.12, + 23.8 + ], + [ + 38.19, + 24.23 + ], + [ + 38.48, + 28.14 + ], + [ + 38.48, + 28.43 + ], + [ + 38.34, + 30.15 + ], + [ + 38.26, + 30.44 + ], + [ + 37.68, + 31.58 + ], + [ + 37.32, + 32.01 + ], + [ + 15.52, + 51.59 + ], + [ + 14.64, + 52.14 + ], + [ + 14.35, + 52.08 + ], + [ + 14.06, + 51.74 + ], + [ + 13.41, + 50.91 + ], + [ + 13.19, + 50.59 + ], + [ + 12.68, + 49.51 + ], + [ + 12.68, + 46.65 + ], + [ + 14.5, + 35.5 + ], + [ + 14.57, + 35.06 + ], + [ + 14.72, + 34.63 + ], + [ + 15.3, + 33.46 + ], + [ + 22.06, + 22.98 + ], + [ + 22.35, + 22.72 + ], + [ + 22.82, + 22.46 + ] + ], + "dx": 17.79, + "dy": 14.42 + }, + { + "name": "renders", + "title": "The renders", + "blurb": "Same framework, different numbers - one function, many clusters. Review the render, not just the template.", + "points": [ + [ + 54.4, + 28.31 + ], + [ + 53.81, + 26.93 + ], + [ + 53.67, + 25.14 + ], + [ + 54.76, + 22.23 + ], + [ + 60.5, + 7.22 + ], + [ + 61.23, + 5.85 + ], + [ + 62.48, + 5.4 + ], + [ + 66.73, + 8.79 + ], + [ + 71.55, + 12.69 + ], + [ + 77.03, + 17.51 + ], + [ + 81.94, + 22.56 + ], + [ + 84.63, + 26.52 + ], + [ + 88.34, + 32.13 + ], + [ + 91.03, + 36.33 + ], + [ + 94.3, + 41.38 + ], + [ + 94.88, + 42.61 + ], + [ + 94.73, + 44.56 + ], + [ + 92.92, + 47.61 + ], + [ + 86.33, + 55.8 + ], + [ + 82.05, + 60.22 + ], + [ + 80.26, + 59.96 + ] + ], + "dx": -15.11, + "dy": 12.16 + }, + { + "name": "gate", + "title": "The schema gate", + "blurb": "values.schema.json + required: a context missing a value fails at render time, in the pipeline.", + "points": [ + [ + 52.58, + 82.79 + ], + [ + 52.58, + 84.22 + ], + [ + 49.17, + 92.01 + ], + [ + 48.73, + 92.99 + ], + [ + 48.44, + 93.51 + ], + [ + 48.04, + 93.56 + ], + [ + 47.89, + 93.43 + ], + [ + 29.76, + 74.97 + ], + [ + 28.74, + 73.82 + ], + [ + 28.31, + 72.96 + ], + [ + 28.23, + 72.68 + ], + [ + 28.23, + 68.29 + ], + [ + 28.67, + 67.36 + ], + [ + 29.03, + 66.72 + ], + [ + 29.69, + 66.48 + ], + [ + 31.58, + 65.77 + ], + [ + 35.11, + 65.04 + ], + [ + 36.17, + 65.04 + ], + [ + 36.51, + 65.3 + ], + [ + 51.56, + 81.1 + ], + [ + 52.0, + 81.66 + ] + ], + "dx": 4.13, + "dy": -10.4 + } + ] +} \ No newline at end of file diff --git a/pilot/manifest-supply.json b/pilot/manifest-supply.json new file mode 100644 index 0000000..fcc55de --- /dev/null +++ b/pilot/manifest-supply.json @@ -0,0 +1,644 @@ +{ + "scene": "supply", + "source": "assets/raw/course-VII-supply-chain.jpeg", + "parts": [ + { + "name": "image", + "title": "The image", + "blurb": "Course I's layers, shipped as one unit - the thing whose name we must say precisely.", + "points": [ + [ + 8.03, + 70.47 + ], + [ + 8.03, + 51.54 + ], + [ + 8.47, + 50.6 + ], + [ + 8.68, + 50.26 + ], + [ + 13.09, + 45.51 + ], + [ + 17.11, + 42.12 + ], + [ + 17.36, + 41.99 + ], + [ + 17.61, + 42.12 + ], + [ + 17.77, + 42.25 + ], + [ + 23.01, + 46.68 + ], + [ + 25.91, + 49.65 + ], + [ + 26.27, + 50.2 + ], + [ + 26.85, + 51.52 + ], + [ + 26.85, + 70.67 + ], + [ + 26.42, + 71.67 + ], + [ + 26.2, + 72.03 + ], + [ + 25.69, + 72.78 + ], + [ + 18.76, + 78.71 + ], + [ + 17.85, + 79.49 + ], + [ + 17.36, + 79.88 + ], + [ + 16.54, + 79.23 + ], + [ + 14.7, + 77.67 + ], + [ + 8.61, + 71.75 + ] + ], + "dx": 26.55, + "dy": -0.01 + }, + { + "name": "registry", + "title": "Upstream registry", + "blurb": "The managed registry CI pushes to - Azure Container Registry in the worked example.", + "points": [ + [ + 31.21, + 82.1 + ], + [ + 31.11, + 38.48 + ], + [ + 31.63, + 37.69 + ], + [ + 31.88, + 37.43 + ], + [ + 34.11, + 35.48 + ], + [ + 37.16, + 32.75 + ], + [ + 37.8, + 32.23 + ], + [ + 38.43, + 32.23 + ], + [ + 39.3, + 33.01 + ], + [ + 43.66, + 36.91 + ], + [ + 44.24, + 37.43 + ], + [ + 44.49, + 37.69 + ], + [ + 45.0, + 38.48 + ], + [ + 45.48, + 45.25 + ], + [ + 45.6, + 63.08 + ], + [ + 44.92, + 81.97 + ], + [ + 44.25, + 83.01 + ], + [ + 43.12, + 84.05 + ], + [ + 41.67, + 85.35 + ], + [ + 38.51, + 88.22 + ], + [ + 38.35, + 88.35 + ], + [ + 37.8, + 88.48 + ], + [ + 33.1, + 84.18 + ], + [ + 32.54, + 83.66 + ], + [ + 31.72, + 82.88 + ] + ], + "dx": 14.12, + "dy": 0.34 + }, + { + "name": "mirror", + "title": "The mirror", + "blurb": "zot, a pull-through cache: the fleet asks it, it asks upstream once, everything after is local.", + "points": [ + [ + 48.51, + 64.72 + ], + [ + 48.87, + 52.91 + ], + [ + 51.05, + 49.54 + ], + [ + 60.12, + 40.69 + ], + [ + 61.98, + 39.78 + ], + [ + 64.72, + 42.12 + ], + [ + 73.22, + 50.34 + ], + [ + 74.38, + 52.54 + ], + [ + 74.75, + 56.78 + ], + [ + 74.46, + 68.54 + ], + [ + 73.87, + 69.89 + ], + [ + 73.29, + 70.67 + ], + [ + 71.48, + 73.01 + ], + [ + 65.04, + 79.23 + ], + [ + 62.2, + 81.58 + ], + [ + 61.46, + 81.71 + ], + [ + 59.38, + 80.67 + ], + [ + 51.27, + 72.31 + ], + [ + 49.53, + 69.74 + ], + [ + 48.8, + 67.85 + ] + ], + "dx": 0.0, + "dy": 0.0 + }, + { + "name": "core", + "title": "The node", + "blurb": "Pulls local. Boot, reschedule and recovery all lean on the mirror being there.", + "points": [ + [ + 75.83, + 58.68 + ], + [ + 75.91, + 50.73 + ], + [ + 76.63, + 49.02 + ], + [ + 76.93, + 48.46 + ], + [ + 78.33, + 47.07 + ], + [ + 80.6, + 45.25 + ], + [ + 82.93, + 43.29 + ], + [ + 84.1, + 42.38 + ], + [ + 84.43, + 42.38 + ], + [ + 85.43, + 43.16 + ], + [ + 87.93, + 45.25 + ], + [ + 89.38, + 46.42 + ], + [ + 91.75, + 48.97 + ], + [ + 92.55, + 50.81 + ], + [ + 92.62, + 52.04 + ], + [ + 92.62, + 68.68 + ], + [ + 92.48, + 69.61 + ], + [ + 92.26, + 70.26 + ], + [ + 91.46, + 72.16 + ], + [ + 90.05, + 73.5 + ], + [ + 84.75, + 77.93 + ], + [ + 84.51, + 78.06 + ], + [ + 84.1, + 78.06 + ], + [ + 83.68, + 77.8 + ], + [ + 79.31, + 74.16 + ], + [ + 76.93, + 72.13 + ], + [ + 76.05, + 70.03 + ], + [ + 75.98, + 69.7 + ], + [ + 75.83, + 68.6 + ] + ], + "dx": -13.37, + "dy": 0.57 + }, + { + "name": "tag", + "title": "The tag", + "blurb": "A promise - a mutable pointer anyone with push rights can move, and nothing records that it moved.", + "points": [ + [ + 26.2, + 20.46 + ], + [ + 26.63, + 19.59 + ], + [ + 27.29, + 18.86 + ], + [ + 32.81, + 12.02 + ], + [ + 37.75, + 7.75 + ], + [ + 37.93, + 7.62 + ], + [ + 38.02, + 7.62 + ], + [ + 39.43, + 8.66 + ], + [ + 40.81, + 9.7 + ], + [ + 43.64, + 11.66 + ], + [ + 44.59, + 13.25 + ], + [ + 49.17, + 18.99 + ], + [ + 49.67, + 19.58 + ], + [ + 50.11, + 20.46 + ], + [ + 50.11, + 22.5 + ], + [ + 49.67, + 23.39 + ], + [ + 49.46, + 23.69 + ], + [ + 47.06, + 26.57 + ], + [ + 38.47, + 34.96 + ], + [ + 38.3, + 35.09 + ], + [ + 38.04, + 35.22 + ], + [ + 37.36, + 34.7 + ], + [ + 27.0, + 23.79 + ], + [ + 26.63, + 23.35 + ], + [ + 26.2, + 22.47 + ] + ], + "dx": 8.22, + "dy": 13.81 + }, + { + "name": "digest", + "title": "The digest", + "blurb": "A fact - sha256 of the content. Same bytes, same digest, forever. The digest does the pulling.", + "points": [ + [ + 49.82, + 20.3 + ], + [ + 50.25, + 19.42 + ], + [ + 51.05, + 18.51 + ], + [ + 53.74, + 15.22 + ], + [ + 56.92, + 11.39 + ], + [ + 61.05, + 8.27 + ], + [ + 61.91, + 7.62 + ], + [ + 65.17, + 10.09 + ], + [ + 67.62, + 12.55 + ], + [ + 72.35, + 18.41 + ], + [ + 73.15, + 19.3 + ], + [ + 73.66, + 20.32 + ], + [ + 73.66, + 22.33 + ], + [ + 73.15, + 23.35 + ], + [ + 69.95, + 27.03 + ], + [ + 63.07, + 34.31 + ], + [ + 62.01, + 35.09 + ], + [ + 61.83, + 35.09 + ], + [ + 60.62, + 34.18 + ], + [ + 58.43, + 32.49 + ], + [ + 53.6, + 27.21 + ], + [ + 50.4, + 23.38 + ], + [ + 49.89, + 22.34 + ], + [ + 49.82, + 20.59 + ] + ], + "dx": -0.06, + "dy": 13.82 + } + ] +} \ No newline at end of file diff --git a/tools/slice-arc.py b/tools/slice-arc.py new file mode 100644 index 0000000..16dbb7a --- /dev/null +++ b/tools/slice-arc.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Segment the delivery-arc heroes into per-part polygons. + +Phase 'debug': label bright components on the void, save numbered overlays for eyeball +assignment. Phase 'emit': given a component->part mapping, write the scene manifest +(percent-coordinate hulls + collapse offsets toward the scene anchor). +""" +import json, sys, pathlib +import numpy as np +from PIL import Image, ImageDraw, ImageFont +from scipy import ndimage +from scipy.spatial import ConvexHull + +ROOT = pathlib.Path(__file__).resolve().parent.parent +W = 1376 # working width + +def load(name): + im = Image.open(ROOT / "assets/raw" / name).convert("RGB") + r = W / im.width + im2 = im.resize((W, int(im.height * r))) + a = np.asarray(im2).astype(np.float32) + lum = a @ np.array([0.299, 0.587, 0.114], dtype=np.float32) + return im2, lum + +def components(lum, thresh=26, dilate=6, min_area=350): + mask = lum > thresh + mask = ndimage.binary_dilation(mask, iterations=dilate) + lab, n = ndimage.label(mask) + out = [] + for i in range(1, n + 1): + ys, xs = np.nonzero(lab == i) + if len(xs) < min_area: + continue + out.append(dict(id=len(out) + 1, xs=xs, ys=ys, + cx=float(xs.mean()), cy=float(ys.mean()), + bbox=(int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())), + area=int(len(xs)))) + return out + +def debug(name, tag): + im, lum = load(name) + comps = components(lum) + d = ImageDraw.Draw(im) + for c in comps: + x0, y0, x1, y1 = c["bbox"] + d.rectangle([x0, y0, x1, y1], outline=(63, 186, 245), width=2) + d.text((c["cx"] - 8, c["cy"] - 10), str(c["id"]), fill=(255, 80, 80)) + out = pathlib.Path(sys.argv[3]) / f"debug-{tag}.png" + im.save(out) + h = im.height + print(f"== {tag} ({len(comps)} components, {W}x{h})") + for c in comps: + print(f" #{c['id']:2d} centroid=({c['cx']/W*100:5.1f}%,{c['cy']/h*100:5.1f}%) " + f"bbox%=({c['bbox'][0]/W*100:.0f},{c['bbox'][1]/h*100:.0f}," + f"{c['bbox'][2]/W*100:.0f},{c['bbox'][3]/h*100:.0f}) area={c['area']}") + +def emit(name, tag, spec): + im, lum = load(name) + comps = {c["id"]: c for c in components(lum)} + h = im.height + parts = [] + for part in spec["parts"]: + xs = np.concatenate([comps[i]["xs"] for i in part["comps"]]) + ys = np.concatenate([comps[i]["ys"] for i in part["comps"]]) + pts = np.stack([xs, ys], 1).astype(np.float64) + hull = ConvexHull(pts) + poly = pts[hull.vertices] + # pad the hull outward from its centroid by ~1.2% of width (glow safety) + c = poly.mean(0) + v = poly - c + poly = c + v * (1 + (W * 0.012) / (np.abs(v).max(1, keepdims=True) + 1e-6)) + # simplify: keep every k-th vertex to <= 18 points + k = max(1, len(poly) // 18) + poly = poly[::k] + parts.append(dict( + name=part["name"], title=part["title"], blurb=part["blurb"], + points=[[round(float(x) / W * 100, 2), round(float(y) / h * 100, 2)] for x, y in poly], + cx=round(float(np.mean(xs)) / W * 100, 2), cy=round(float(np.mean(ys)) / h * 100, 2))) + ax, ay = next((p["cx"], p["cy"]) for p in parts if p["name"] == spec["anchor"]) + for p in parts: + nest = spec.get("nest_overrides", {}).get(p["name"], spec.get("nest", 0.72)) + p["dx"] = round((ax - p["cx"]) * nest, 2) + p["dy"] = round((ay - p["cy"]) * nest, 2) + del p["cx"]; del p["cy"] + man = dict(scene=tag, source=f"assets/raw/{name}", parts=parts) + out = ROOT / "pilot" / f"manifest-{tag}.json" + out.write_text(json.dumps(man, indent=1)) + print(f"manifest-{tag}.json: {len(parts)} parts ->", + ", ".join(f"{p['name']}({p['dx']},{p['dy']})" for p in parts)) + +if __name__ == "__main__": + mode, scenes_json, outdir = sys.argv[1], sys.argv[2], sys.argv[3] + scenes = json.loads(pathlib.Path(scenes_json).read_text()) if mode == "emit" else None + heroes = [("course-VI-gitops.jpeg", "gitops"), + ("course-VII-supply-chain.jpeg", "supply"), + ("course-VIII-helm-press.jpeg", "helm")] + for name, tag in heroes: + if mode == "debug": + debug(name, tag) + else: + emit(name, tag, scenes[tag])