48 Commits

Author SHA1 Message Date
jwright cf900b3409 images: bust CF cache for the de-watermarked art; add the dewatermark tool
build-and-deploy / build (push) Failing after 10m31s
public/ assets are edge-cached under non-hashed URLs, so the scrubbed covers
and heroes were still being served stale. Project covers carried no cache-bust
at all - added ?v=3 to both cover references and bumped the blog heroes from
v2 to v3. Adds scripts/dewatermark.mjs implementing the documented approach
(hard-coded mark centre, feathered disc, border-mean seed, Jacobi diffusion)
rather than the ffmpeg delogo used in the previous pass.
2026-08-25 18:20:29 +10:00
jwright 4a8552c408 images: remove generator watermark from all published art
build-and-deploy / build (push) Failing after 10m22s
The pale four-point star bottom-right in seven project covers and four blog
heroes is the image generator's watermark, not house style. Scrubbed via
delogo and verified numerically (peak luminance in the mark's box drops from
~100-255 to under 30 in every file); thumbnails regenerated from the cleaned
masters. The cover prompt file wrongly described the mark as part of the house
look and asked new generations to include one - corrected, with a reject-list
entry naming the mark and its usual position.
2026-08-25 18:00:38 +10:00
jwright b5e0b25667 site update: cover art for the learn-site case studies; publish untrusted-in-a-vm (SECURITY #7)
build-and-deploy / build (push) Failing after 11m8s
2026-08-25 17:24:51 +10:00
jwright c5b1044d7a projects: add The Mirror pull-through registry case study; wire cover art for both learn-site projects
build-and-deploy / build (push) Failing after 12m49s
Adds mirror-registry as a featured project (order 26, beside The Exploded
Cluster) covering the zot deployment, the bootstrap and auth circular
dependencies, and the retention/revalidation behaviours that corrected the
first published write-up. Adds the cover: field to exploded-cluster (it had
none, so the card rendered imageless) and stages public/covers/image.md with
the generation prompts and processing steps for both covers.
2026-08-25 09:18:11 +10:00
jwright 8d394fe2c7 nav hover: glow instead of the underline bar, and give keyboard focus the same glow
build-and-deploy / build (push) Failing after 11m32s
2026-08-18 11:04:27 +10:00
jwright 21616762e6 nav: add a Learn tab pointing at the teaching site, and drop the scroll-spy underline - tracking the active section as you scrolled read as clunky, so the underline is hover-only now; also refresh the public CV
build-and-deploy / build (push) Failing after 15m10s
2026-08-18 10:05:49 +10:00
jwright f6b683713e publish the public CV and fix the availability check to resolve from the project root - Vite rewrites module URLs at build so the relative walk never found public/
build-and-deploy / build (push) Failing after 14m0s
2026-08-18 09:56:59 +10:00
jwright b6d3d4487a offer the CV download in the contact section too, so a recruiter who scrolls straight there can grab it
build-and-deploy / build (push) Failing after 10m40s
2026-08-18 09:50:21 +10:00
jwright dd2207bfa6 add the exploded cluster as a project with a live link to learn.bztmon.com
build-and-deploy / build (push) Failing after 15m2s
2026-08-18 09:40:58 +10:00
jwright 51d1ccb149 README: describe the JS that actually ships instead of claiming zero, and note why the site still works with JS disabled 2026-08-18 02:03:57 +10:00
jwright 36444c3424 corrections from Jonathon: Unimus and NetBox were different jobs on different infrastructure, so NetBox comes out of the network project entirely; the Google migration was off GCP and Google Workspace onto Windows AD, Microsoft 365 and Azure; the ERP refresh included daily COBOL green-screen work; contact line kept in one place
build-and-deploy / build (push) Failing after 11m10s
2026-08-18 01:54:49 +10:00
jwright 7cbc3629a6 second pass: name the real GPU mechanism (resource request schedules, init container gates startup, probes track runtime health), scope the network DR and reversibility claims to what config backup actually gives you, drop production-grade, and colour the education hash instead of the first letter
build-and-deploy / build (push) Failing after 10m52s
2026-08-18 01:50:07 +10:00
jwright c07f8433dc revert the homepage JSON-LD: application/ld+json is still an inline script under script-src 'self', and the no-inline-script posture is worth more than the structured data
build-and-deploy / build (push) Successful in 15s
2026-08-18 01:36:47 +10:00
jwright 0f85bd9870 add Person and WebSite structured data on the homepage so a search for the name resolves to the right engineer
build-and-deploy / build (push) Failing after 10m35s
2026-08-18 01:15:24 +10:00
jwright 6155aca055 bastion post: drop the host address and the list of credentials it holds - the architecture makes the point without the inventory
build-and-deploy / build (push) Failing after 11m58s
2026-08-18 01:13:57 +10:00
jwright 573e7a6002 blog: give each post its own closing heading instead of sixteen sections called 'The principle'
build-and-deploy / build (push) Failing after 13m5s
2026-08-18 01:12:54 +10:00
jwright 25425d6f6a portfolio pass: correct the CSP claim to match what nginx sends, rename the project outcome label, plain-english the projects intro and bio, standardise on Argo CD and single-touch, drop the education filler line
build-and-deploy / build (push) Failing after 14m34s
2026-08-18 01:11:25 +10:00
jwright 7542ae2665 turn off smartypants so the rendered pages stay ascii
build-and-deploy / build (push) Failing after 12m30s
2026-08-17 23:23:31 +10:00
jwright 43188589ab sweep the last typographic punctuation out of the components and data files
build-and-deploy / build (push) Failing after 14m21s
2026-08-17 23:21:38 +10:00
jwright 3cfd59a464 copy pass: straighten quotes and arrows, thin the dash asides, drop the app-count caption from the constellation
build-and-deploy / build (push) Failing after 13m18s
2026-08-17 23:12:42 +10:00
jwright 78e8740408 deps: clear the nanoid and ip-address high advisories flagged by the audit gate
build-and-deploy / build (push) Failing after 12m55s
2026-08-17 02:08:03 +10:00
jwright e90bf177a5 blog: open the EDGE AI, AUTOMATION and OBSERVABILITY arcs
build-and-deploy / build (push) Failing after 10m35s
Three arc-opening posts, each with hero and diagram:
- probation-for-models (EDGE AI 01)
- one-value-many-enforcers (AUTOMATION 01)
- six-days-of-silence (OBSERVABILITY 01)
2026-08-17 01:50:25 +10:00
jwright 6c0d7643a6 gitignore python bytecode 2026-08-05 02:02:11 +10:00
jwright 3a13cff1fa gitignore local blog-workshop tooling 2026-08-04 12:31:44 +10:00
jwright df72a22952 docs: refresh README for the current layout; ASCII sweep on SECURITY.md 2026-08-04 03:45:58 +10:00
jwright f89957370e gitignore local staging
Draft posts are private staging. They reach the public only by being moved
into src/content/blog at publish time, deliberately, one at a time.
2026-08-04 03:28:31 +10:00
jwright d26e981d7a blog: expand the series to six pillars; add the catalogue generator
Drafted from the last month of real work - OpenShift fleet hardening, edge AI
and GPU, network policy enforcement, supply-chain security, automation and
observability - weighted toward the work stack the site advertises. Each has
a photo prompt in the house palette and a diagram brief.

Adds BLOG-LEDGER.md, generated from disk so it cannot drift, listing every
live and drafted idea in one place. Search it before drafting; claim the idea
when the folder is created rather than at publish, so two sessions cannot
collide. Expands the series from three arcs to six now the catalogue is deep
enough to split edge AI, automation and observability out of PLATFORM.
2026-08-04 03:21:49 +10:00
jwright cbfc4007cf projects: cover art on the dossier cards, replacing the sketch thumbnails
The inline SVG thumbs were placeholder-grade next to real art. Cards now use
a 640px mini of the same cover (9-22KB), object-fit cover with a slow hover
zoom, and a corner scrim so the DOSSIER tag stays legible over the brighter
frames. Falls back to the sketch for any project without a cover.
2026-08-03 17:57:44 +10:00
jwright cd08cd04c1 projects: cover art on every case study
Six generated covers wired in via an optional cover key - 2528x1696 sources
optimised to 1600px webp, 49-144KB each. They sit on the page background
rather than the diagrams' light card, since the art is already generated in
the site palette on near-black. Cover carries the story at a glance, the
diagram still carries the architecture below it.
2026-08-03 17:53:25 +10:00
jwright ba81705a39 projects: drop the two diagram refs that never had files
gpu-as-code and global-infra-modernisation pointed at diagrams that do not
exist, rendering a dead image box on a light card. Removing the key is
better than a broken frame until real art lands. Prompt pack for all six
covers ships alongside, in the blog-hero visual language.
2026-08-03 17:34:07 +10:00
jwright 99fd652569 projects: author the missing network-fleet diagram (was a 404 on the case study) 2026-08-03 17:32:52 +10:00
jwright afba75dd3a capabilities: keep the video-analytics line vendor-neutral 2026-08-03 16:58:14 +10:00
jwright f7e64cebe5 capabilities: proof-points fill the tall tiles; headers rebalanced to the flagship weight
The hero and full-row tiles stretched to their grid height with nothing
between blurb and chips - reading as missing body. Adds a points field
rendered as accent-dashed proof lines (flagship gets four, observability
three), each grounded in the running estate. Category titles step up to
23px/700 with the flagship at 30px so the hierarchy holds.
2026-08-03 16:54:53 +10:00
jwright 6c7e577633 blog: three curated series across the whole catalogue + badge on post pages
Extends the lone security thread into three arcs that read as skill pillars -
SECURITY (6), NETWORKING (5), PLATFORM (7) - so every post carries a numbered
badge and the catalogue advertises three areas of depth rather than a loose
list. Adds the badge to the post detail header too, styled to match the cards.
2026-08-03 16:47:07 +10:00
jwright 6ea2582a66 blog: heroes belong in frontmatter - fix the five new posts
The five carried the hero only as an inline markdown image, but the index
cards and the post hero figure render from hero/heroAlt frontmatter - so
the cards showed no image and the post pages lost the hero treatment.
Moved each hero into frontmatter and dropped the inline line (the body
keeps only the diagram), matching every earlier post.
2026-08-03 08:24:22 +10:00
jwright 317e5230b6 deps: clear the audit gate (astro 7.0.10 + transitive fixes); reword one-doorman past the secret scan
npm audit fix - 13 advisories incl. astro high and tar critical, all
non-breaking, gates re-run green locally. The dist secret scan greps
password-adjacent patterns, so the post now says htpasswd file (which the
registry auth actually is), credential vault, and lock-of-its-own.
2026-08-02 23:34:17 +10:00
jwright 521cc2f066 blog: publish five posts - landing-on-cilium, sleep-half-the-fleet, clone-not-keys, stage-the-locks, one-doorman-many-doors
Heroes optimised to 1600px webp from the prepped batch; diagrams authored
to the house palette; dates set to publish day.
2026-08-02 23:17:31 +10:00
jwright 91b3cff67a blog: publish three posts — observe-first-deny-second, snat-ate-my-source, no-ssh
Heroes de-watermarked via the calibrated Jacobi diffuse (dewatermark.mjs now
parameterised src/out), diagrams authored in the house light-card style.
2026-07-14 18:43:32 +10:00
jwright a0d71388c4 constellation: seamless animation loops — dash offset aligned to the 136px pattern period (dots no longer jump at the seam), radar ring fades in instead of popping 2026-07-13 15:32:09 +10:00
jwright 3217d779b3 constellation: key to the right edge (left clips on narrow heroes), bat-gloss swatch for core/services 2026-07-13 15:16:24 +10:00
jwright 94698c6315 constellation: intel accent for alfred + elfastc, compute colour key, drop the stale kate caption 2026-07-13 15:00:11 +10:00
jwright 87cfe78ffc blog: publish three posts — backup restore drills, the bastion pattern, split-horizon DNS-01 (heroes + diagrams) 2026-07-09 15:02:17 +10:00
jwright 2896f89af3 footer: unify the receipt + identity into one terminal session panel; fix HEAD sha in container builds (GIT_SHA build-arg) 2026-07-09 14:04:54 +10:00
jwright 908d311e5c fix(skills): collapse bento spans at 2-col breakpoint, not 720px
Landscape phone (720-1000px band) put the parent grid at 2 columns while the
tile spans (hero span-2, full span-3) only reset at 720px. A span-3 tile in a
2-col grid forced a phantom 3rd column and collapsed the 1fr tracks to
58px 58px 760px, squishing the flagship card to ~132px off the visible area.
Move the span reset to the same 1000px breakpoint the grid drops to 2 columns:
now 446px 446px, hero full-width. Desktop 4-col + portrait 1-col unchanged.
2026-07-03 23:56:07 +10:00
jwright 0d05aae721 blog: republish 2 edited posts, dossier thumbnails 4-6, new post broker-pattern
Content: shipping-this-site + init-gating-gpu-readiness get their edited-workshop bodies (from the
NAS Published/ pass) - site frontmatter (hero/heroAlt) kept, only prose replaced.

Dossiers 004-006 (gpu-as-code, global-infra-modernisation, network-automation-fleet) were all
rendering the same generic pipeline thumbnail - added 3 tailored DossierThumb motifs (gpu die +
passthrough lanes, region globe, switch-fleet grid) via a new diagram: frontmatter field. Also
closed the dead-space gap above the tag row on short-argument cards with a faint on-theme grid
fill (::before, masked fade) instead of a flat void.

New post: broker-pattern ('An agent should never hold the key it's using') - SECURITY SERIES 02,
between secret-zero and workload-least-privilege. Hero de-watermarked from the NAS source (resized
to the calibrated 1600x1073 coordinate space, Jacobi-diffused out the Nano Banana sparkle at its
known center) via scripts/dewatermark-broker-pattern.mjs (reusable for the next 3 drafts). New
diagram/broker-pattern.svg in the house light-card palette. Inline top-of-body hero stripped per
the astro-static-site skill guard (frontmatter hero: is now the only render path).
2026-07-03 21:12:52 +10:00
jwright c162f2e4e7 nav: phone tabs on a compact second row - zero-JS, scrollable, no hamburger
The links were display:none under 720px (desktop-only nav). Phones now wrap the bar: brand +
theme toggle row 1, the five tabs row 2 (mono 0.78rem, overflow-x scroll, hidden scrollbar).
Media block placed AFTER the base rules - same-specificity source order was eating the first attempt.
2026-07-03 20:47:09 +10:00
jwright de7c257ab7 hero: wrap the fleet-fact bar on phones - nowrap+max-content busts a 390px viewport 2026-07-03 20:16:52 +10:00
jwright 71c3f00618 facelift: the Bat-Computer redesign - constellation hero, bento, dossiers, receipt
Promotes the blog art identity to the whole site. Fleet constellation hero (six real nodes,
dual-trace packets, typing fleet facts), Chakra Petch + JetBrains Mono self-hosted, bento
capabilities, dossier work cards, security-series badges, featured blog index, GitOps receipt
footer, native view transitions, light blueprint theme. CSP strict throughout; blog content
byte-identical.
2026-07-03 20:08:12 +10:00
123 changed files with 4054 additions and 1036 deletions
+15
View File
@@ -41,3 +41,18 @@ secrets.*
# CI artifact
sbom.json
# Blog drafts are PRIVATE staging - this repo is public and anonymously readable.
# Nothing under drafts/ may ever be committed; a draft becomes public only by
# being moved into src/content/blog/ at publish time.
drafts/
# Publish-workflow archives stay local for the same reason.
published/
# Local blog-workshop tooling stays off the public repo.
scripts/make-ledger.py
scripts/sync-nas-blog.sh
# python bytecode from the local ledger script
scripts/__pycache__/
+4 -1
View File
@@ -13,7 +13,10 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Build the static site.
# Build the static site. The git sha arrives as a build-arg (.dockerignore excludes .git,
# so an in-container `git rev-parse` can never resolve — the footer receipt read "HEAD unknown").
ARG GIT_SHA
ENV GIT_SHA=${GIT_SHA}
COPY . .
RUN npm run build
+45 -54
View File
@@ -1,18 +1,22 @@
# bztmon-site
The source for **[www.bztmon.com](https://www.bztmon.com)** — Jonathon Wright's
portfolio / résumé site. A fast, animated, security-hardened static site for a
platform / infrastructure engineer.
Source for [www.bztmon.com](https://www.bztmon.com) - portfolio and blog for a
platform / infrastructure engineer. Static output, no server runtime, hardened
by default.
> This repo is **public**. It lives on a self-hosted public Gitea (`git.bztmon.com`),
> isolated from the private homelab GitOps. **Never commit secrets** — the static
> site needs none.
> This repo is public and lives on a self-hosted Gitea (`git.bztmon.com`),
> isolated from the private GitOps repos. The static site needs no secrets;
> none are committed.
## Stack
- **Astro** (static output) + **TypeScript** + **Tailwind v4**
- Zero JS by default; tiny islands for the theme toggle + scroll reveals
- Content & config are data-driven (`src/data/`) — adding a project never touches a component
- Astro (static output), TypeScript, Tailwind v4
- Static output with one self-hosted client bundle (~74 KB: Motion for scroll reveals, plus a
small pre-paint theme script). No inline script anywhere, so `script-src 'self'` holds.
- Content is visible with JavaScript disabled: reveal styles are gated behind an `html.js`
class the script adds, and `prefers-reduced-motion` forces the visible state
- Content and config are data-driven (`src/data/`, `src/content/`) - adding a
post or project never touches a component
## Develop
@@ -20,72 +24,59 @@ platform / infrastructure engineer.
npm install
npm run dev # http://localhost:4321
npm run check # astro check (types + diagnostics)
npm run build # static build dist/
npm run build # static build -> dist/
npm run preview # serve the build locally
npm run gen:og # regenerate the social-preview image (public/og.png)
```
## Project layout
## Layout
```
src/
data/ site.ts, socials.ts, skills.ts, projects.ts, experience.ts
components/ Hero, Nav, ThemeToggle, ProjectCard, SkillGroup, ...
content/blog/ posts (Markdown, zod-validated frontmatter)
content/projects/ project dossiers
data/ site.ts, series.ts, skills.ts, projects.ts, experience.ts
components/ Hero, Nav, PostList, ProjectCard, ...
layouts/ Layout.astro (SEO/OG, theme bootstrap)
pages/ index.astro, projects/, 404.astro
pages/ index, blog/, projects/, 404
styles/ tokens.css (theme), global.css
lib/ build-time helpers (cv detection)
scripts/ gen-og.mjs, build-image.sh
scripts/ gen-og.mjs, build-image.sh, new-post.mjs
public/ heroes (blog/), diagrams (diagrams/), og.png, cv
nginx/ default.conf (security headers, caching) baked into the image
Dockerfile Debian build stage nginx-unprivileged runtime
Dockerfile Debian build stage -> nginx-unprivileged runtime
```
## Content TODOs (Jonathon)
## Posts
- Drop a real CV at `public/cv.pdf` — the **Download CV** button appears automatically.
- Fill the `TODO(Jonathon)` markers in `src/data/experience.ts`, `projects.ts`, `socials.ts`
(employer names, dates, GitHub/LinkedIn handles).
## Publishing a post
A post is just a Markdown file in `src/content/blog/`. Write one by hand, or generate
a schema-valid one with the publish helper (this is the seam an IaC/CI step calls):
```bash
# from flags
node scripts/new-post.mjs --title "My post" --summary "One line" \
--tags "kubernetes,gpu" [--draft] [--bodyFile notes.md]
# from a JSON event (e.g. an Ansible/AWX deploy summary)
echo '{"title":"...","summary":"...","tags":["x"],"body":"## Hi\n..."}' \
| node scripts/new-post.mjs --stdin
```
Commit the file to `main` → CI rebuilds and ships. A malformed post **fails the build**
(frontmatter is zod-validated), so a bad pipeline event never reaches production.
A post is a Markdown file in `src/content/blog/` with zod-validated
frontmatter; a malformed post fails the build, so a bad file never reaches
production. `scripts/new-post.mjs` scaffolds one from flags or a JSON event on
stdin - the seam a CI step can call. Each post belongs to one numbered series
(`src/data/series.ts`), which drives the badge on the index cards.
## CI/CD
`.gitea/workflows/deploy.yml` runs on a self-hosted runner (a dedicated unprivileged
user on the bastion):
`.gitea/workflows/deploy.yml` runs on a self-hosted runner (a dedicated
unprivileged user on a utility host):
```
npm ci astro check audit-ci (high/critical gate) build scan dist
SBOM (CycloneDX) buildah build+push open a digest-bump PR to home-ops
npm ci -> astro check -> audit-ci (high/critical gate) -> build -> scan dist ->
SBOM (CycloneDX) -> buildah build+push -> open a digest-bump PR to home-ops
```
The PR is **never auto-merged**`home-ops` `main` is branch-protected; merging it is
what triggers the ArgoCD rollout. The runner holds only least-privilege creds (a
`home-ops`-scoped deploy key + a PR token + a registry push token).
The PR is never auto-merged - the deploy repo's `main` is branch-protected, and
merging the PR is what triggers the Argo CD rollout. The runner holds
least-privilege credentials only: a scoped deploy key, a PR token, and a
registry push token.
- `npm run scan` build-time gate: no secrets, no inline scripts, no third-party origins.
- `.audit-ci.json` — fails on high/critical advisories. One allowlisted: `GHSA-gv7w-rqvm-qjhr`
(esbuild install-integrity; build-time only, mitigated by the committed lockfile + trusted registry).
- `renovate.json` keeps npm deps and the digest-pinned base images current.
- `npm run scan` - build-time gate: no secrets, no inline scripts, no
third-party origins in `dist/`
- `.audit-ci.json` - fails the build on high/critical advisories
- `renovate.json` - keeps npm deps and the digest-pinned base images current
## Deploy
Built into a container image, served by nginx-unprivileged on a homelab Kubernetes
cluster, exposed via Cloudflare Tunnel. The image is pinned by digest in the private
`home-ops` repo and rolled out by ArgoCD. Manual/bootstrap build: `scripts/build-image.sh push`.
See `SECURITY.md` for the full security posture.
Built into a container image, served by nginx-unprivileged on Kubernetes,
exposed outbound-only via Cloudflare Tunnel. The image is pinned by digest in
the private deploy repo and rolled out by Argo CD. Manual bootstrap build:
`scripts/build-image.sh push`. Full posture in `SECURITY.md`.
+9 -9
View File
@@ -5,17 +5,17 @@ as acceptance criteria, not polish.
## Attack surface
- **Static output** no server runtime, no database, no user input, no forms.
- **Static output** - no server runtime, no database, no user input, no forms.
- Served by `nginx-unprivileged` (uid 101, read-only root filesystem, all Linux
capabilities dropped, no service-account token) on Kubernetes.
- Exposed **outbound-only via a Cloudflare Tunnel** no open inbound ports, a single
- Exposed **outbound-only via a Cloudflare Tunnel** - no open inbound ports, a single
public hostname, no catch-all.
## Headers
Split by where they belong:
**Origin (nginx, ships in the image `nginx/security-headers.conf`)**
**Origin (nginx, ships in the image - `nginx/security-headers.conf`)**
| Header | Value |
|---|---|
@@ -23,11 +23,11 @@ Split by where they belong:
| X-Content-Type-Options | `nosniff` |
| X-Frame-Options | `DENY` |
| Referrer-Policy | `strict-origin-when-cross-origin` |
| Permissions-Policy | camera/mic/geo/payment/usb all denied |
| Permissions-Policy | camera/mic/geo/payment/usb... all denied |
| Cross-Origin-Opener-Policy | `same-origin` |
| Cross-Origin-Resource-Policy | `same-origin` |
**Edge (Cloudflare dashboard)** HSTS, HTTPHTTPS redirect, SSL Full, Bot Fight,
**Edge (Cloudflare dashboard)** - HSTS, HTTP->HTTPS redirect, SSL Full, Bot Fight,
rate-limiting. HSTS ships **without** `includeSubDomains`/`preload` initially because
`*.bztmon.com` resolves to the WAN and the subdomain form would brick non-public hosts.
@@ -36,7 +36,7 @@ rate-limiting. HSTS ships **without** `includeSubDomains`/`preload` initially be
- **`script-src 'self'` with zero inline scripts.** All JS (pre-paint theme, toggle,
scroll reveal) lives in one external `/site.js`. No `unsafe-inline`, no `unsafe-eval`,
no hashes to maintain.
- **`style-src` allows `'unsafe-inline'`** the one conscious exception. Shiki's
- **`style-src` allows `'unsafe-inline'`** - the one conscious exception. Shiki's
dual-theme syntax highlighting emits per-token CSS custom-properties as inline `style`
attributes; hashing them is impractical (they vary per page). The security-critical
directive (`script-src`) stays strict. Everything else is self-hosted: fonts are system
@@ -55,9 +55,9 @@ Runs in CI before the image is built.
## Targets (verified post-deploy)
- securityheaders.com A+
- Mozilla Observatory A/A+ (small deduction expected for `style-src 'unsafe-inline'`)
- Lighthouse 95 across Performance / Accessibility / Best Practices / SEO
- securityheaders.com -> A+
- Mozilla Observatory -> A/A+ (small deduction expected for `style-src 'unsafe-inline'`)
- Lighthouse >= 95 across Performance / Accessibility / Best Practices / SEO
- CSP: zero console violations in a real browser (incl. code blocks + diagrams)
## Reporting
+4 -2
View File
@@ -8,7 +8,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
site: "https://www.bztmon.com",
// Always emit/expect trailing slashes so internal links hit the final URL directly
// (no nginx dir-redirect no stray :8080 / cached 301s).
// (no nginx dir-redirect -> no stray :8080 / cached 301s).
trailingSlash: "always",
integrations: [sitemap()],
build: {
@@ -16,8 +16,10 @@ export default defineConfig({
inlineStylesheets: "never",
},
markdown: {
// Keep rendered prose ASCII: no curly-quote/dash typesetting at build.
smartypants: false,
shikiConfig: {
// Dual theme via CSS variables colours switch with our data-theme, no
// Dual theme via CSS variables -> colours switch with our data-theme, no
// hard-coded per-token colours baked to one theme.
themes: { light: "github-light", dark: "github-dark" },
defaultColor: false,
+530 -483
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+101
View File
@@ -0,0 +1,101 @@
# Project cover prompts - bztmon.com
Two covers to generate: **The Exploded Cluster** and **The Mirror (pull-through registry)**.
Paste the block below the `---` for each one into Gemini / Nano Banana 2, generate ~4
variations, curate against the reject list, then process per "After generating".
Aspect: **3:2 landscape** (existing covers are 1600 x 1074).
---
## House style block (already inside each prompt - do not paste separately)
The site's covers are dark isometric hero renders: matte dark hardware, cyan and magenta
edge light, a subtly circuit-etched ground plane, thin atmospheric haze, and generous empty space on one
side so the card's title text has room.
Unlike the learn-site scene plates, covers MAY have a ground plane and atmosphere - they are
never sliced or animated.
## Reject checklist (per candidate, before you keep it)
- [ ] any text, letters, numbers, logos or UI chrome anywhere in the frame (reject hard)
- [ ] (!) THE GENERATOR WATERMARK - a small pale four-point star, usually bottom-right at
roughly 88% width / 82% height. It is NOT house style; earlier covers shipped with it
by mistake and had to be scrubbed. Check that corner on every candidate, and never ask
a prompt for a "sparkle highlight" (that instruction produced a second, drawn-in star
that sits on the artwork and cannot be cleanly removed)
- [ ] subject clipped by the frame edge, or centred so tightly there is no negative space
- [ ] colours drifting warm - the palette is cyan/magenta on near-black, no orange or gold
- [ ] photographic realism or stock-render gloss (it should read as an illustration)
- [ ] busy background competing with the subject
- [ ] the two covers looking like the same object (they must be distinguishable as a pair)
## After generating
1. Keep the best of each, name them `exploded-cluster.png` / `mirror-registry.png`.
2. Convert and place (from the repo root, on the box with the site checkout):
```
# full-size cover: 1600px wide
ffmpeg -y -i exploded-cluster.png -vf "scale=1600:-2" -q:v 80 public/covers/exploded-cluster.webp
ffmpeg -y -i mirror-registry.png -vf "scale=1600:-2" -q:v 80 public/covers/mirror-registry.webp
# card thumbnail: 640px wide
ffmpeg -y -i exploded-cluster.png -vf "scale=640:-2" -q:v 80 public/covers/thumb/exploded-cluster.webp
ffmpeg -y -i mirror-registry.png -vf "scale=640:-2" -q:v 80 public/covers/thumb/mirror-registry.webp
```
3. Both project files already reference these names (`cover: "exploded-cluster"`,
`cover: "mirror-registry"`), so the cards pick them up on the next build.
---
# PROMPT 1 - The Exploded Cluster
Dark isometric technical illustration in a cinematic sci-fi engineering style, 3:2 landscape.
Near-black background, hex #070b14, with a faint dark circuit-etched ground plane beneath the
subject and a thin drift of atmospheric haze in the upper right. Matte dark metal and smoked
glass surfaces with glowing neon edge lighting: primary cyan #3fbaf5, secondary magenta
#e879f9, cool white rim highlights. Even studio lighting, no lens flare, no depth-of-field
blur. ABSOLUTELY NO text, letters, numbers, logos, labels, arrows or UI elements anywhere in
the image - surface markings must be abstract geometric patterns only.
The subject: a single machine caught mid-explosion, its layers separated vertically like an
engineering teardown diagram, floating apart with clear gaps of void between them. From the
bottom up: a heavy dark chassis base with cyan-lit vents; above it a thick slab housing rows
of small identical cells; above that two thin translucent glass sheets etched with fine
circuit tracery, glowing faintly cyan; and at the top, held well clear of everything else, a
small bright magenta-lit cube - the smallest and most luminous part, clearly the payload the
whole machine exists to carry.
The parts hover in stable formation rather than flying apart violently - a considered
teardown, paused for inspection. Composition sits left of centre, leaving open dark space on
the right third of the frame.
---
# PROMPT 2 - The Mirror (pull-through registry)
Dark isometric technical illustration in a cinematic sci-fi engineering style, 3:2 landscape.
Near-black background, hex #070b14, with a faint dark circuit-etched ground plane beneath the
subject and a thin drift of atmospheric haze in the upper left. Matte dark metal and smoked
glass surfaces with glowing neon edge lighting: primary cyan #3fbaf5, secondary magenta
#e879f9, cool white rim highlights. Even studio lighting, no lens flare, no depth-of-field
blur. ABSOLUTELY NO text, letters, numbers, logos, labels, arrows or UI elements anywhere in
the image - surface markings must be abstract geometric patterns only.
The subject: a wide, low, heavily-armoured way-station machine sitting on the ground plane,
right of centre - a bunker-grade cache with a glass-fronted shelf running along its face,
holding a row of small identical luminous cyan bricks in cold storage. Its face has a single
broad output aperture ringed in cool white light, aimed left.
Above and behind it, arranged in a loose arc receding into the haze, five small dark intake
pods at clearly diminishing scale - each a compact rounded capsule with one cyan-lit aperture,
each floating alone with generous void between them, none touching the main machine. They
read as five distant sources feeding one local shelf.
Below and left of the way-station, close to the ground plane, a single luminous cyan brick
hovers in mid-transfer - small, bright, clearly in motion toward the viewer's side of the
frame, the one thing the whole arrangement is delivering.
Composition sits right of centre, leaving open dark space on the left third for title text.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Executable
BIN
View File
Binary file not shown.
+63
View File
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="440" viewBox="0 0 1180 440" role="img" aria-label="One bastion control point holds the fleet's keys — one kubeconfig, one talosconfig, one age key, every ops repo — and everything is driven through it: laptops and desktops connect only via the bastion, which reaches out to the five clusters and the git server. The keys never leave the station.">
<rect width="1180" height="440" fill="#ffffff"></rect>
<defs>
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#0c8fce"></path>
</marker>
<marker id="ag" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#94a3b8"></path>
</marker>
</defs>
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">One place the keys live &#8212; everything transits it</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the bastion is a property, not a box: the set of places your keys exist has exactly one member</text>
<!-- operator devices (left) -->
<rect x="48" y="150" width="176" height="60" rx="12" fill="#ffffff" stroke="#94a3b8" stroke-width="1.4"></rect>
<text x="136" y="176" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#64748b">laptop</text>
<text x="136" y="196" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#94a3b8">holds no keys</text>
<rect x="48" y="230" width="176" height="60" rx="12" fill="#ffffff" stroke="#94a3b8" stroke-width="1.4"></rect>
<text x="136" y="256" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#64748b">desktop</text>
<text x="136" y="276" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#94a3b8">holds no keys</text>
<!-- the bastion (centre, the station) -->
<rect x="366" y="150" width="240" height="170" rx="16" fill="#7c3aed" fill-opacity="0.05" stroke="#7c3aed" stroke-width="2.4"></rect>
<text x="486" y="184" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="16" font-weight="700" fill="#7c3aed">bastion</text>
<text x="486" y="205" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">the pilot station &#183; 4-core VM</text>
<text x="486" y="232" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">1 kubeconfig &#183; 1 talosconfig</text>
<text x="486" y="252" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">1 age key &#183; every ops repo</text>
<text x="486" y="272" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">toolchain pinned to the fleet</text>
<text x="486" y="300" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" font-weight="700" fill="#7c3aed">keys never leave here</text>
<!-- transit arrows in -->
<path d="M224 180 C300 180 320 210 362 216" fill="none" stroke="#94a3b8" stroke-width="2" marker-end="url(#ag)"></path>
<path d="M224 260 C300 260 320 250 362 244" fill="none" stroke="#94a3b8" stroke-width="2" marker-end="url(#ag)"></path>
<text x="300" y="150" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">connect THROUGH</text>
<!-- the fleet (right, the ships) -->
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<rect x="760" y="120" width="150" height="40" rx="10" fill="#ffffff" stroke="#0c8fce" stroke-width="1.5"></rect>
<text x="835" y="145" text-anchor="middle">cave</text>
<rect x="940" y="120" width="150" height="40" rx="10" fill="#ffffff" stroke="#0c8fce" stroke-width="1.5"></rect>
<text x="1015" y="145" text-anchor="middle">alfred</text>
<rect x="760" y="176" width="150" height="40" rx="10" fill="#ffffff" stroke="#0c8fce" stroke-width="1.5"></rect>
<text x="835" y="201" text-anchor="middle">robin</text>
<rect x="940" y="176" width="150" height="40" rx="10" fill="#ffffff" stroke="#0c8fce" stroke-width="1.5"></rect>
<text x="1015" y="201" text-anchor="middle">wgirl</text>
<rect x="760" y="232" width="150" height="40" rx="10" fill="#ffffff" stroke="#0c8fce" stroke-width="1.5"></rect>
<text x="835" y="257" text-anchor="middle">elfastc</text>
<rect x="940" y="232" width="150" height="40" rx="10" fill="#ffffff" stroke="#7c3aed" stroke-width="1.5"></rect>
<text x="1015" y="257" text-anchor="middle" fill="#7c3aed">git server</text>
</g>
<!-- beams out to the fleet -->
<g fill="none" stroke="#0c8fce" stroke-width="2">
<path d="M606 200 C680 180 700 150 756 140" marker-end="url(#a)"></path>
<path d="M606 214 C700 210 720 196 936 196" marker-end="url(#a)"></path>
<path d="M606 232 C680 250 700 252 756 252" marker-end="url(#a)"></path>
</g>
<path d="M606 244 C740 300 860 270 936 252" fill="none" stroke="#7c3aed" stroke-width="2" marker-end="url(#a)"></path>
<text x="690" y="330" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">driven from one hostname &#183; auditing &#8220;where from?&#8221; takes a minute</text>
<text x="60" y="404" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" fill="#64748b">ships come and go &#8212; the charts never leave the station</text>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

+60
View File
@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="430" viewBox="0 0 1180 430" role="img" aria-label="The broker-pattern flow: an untrusted agent asks a credential broker, which mints a scoped short-lived capability without exposing any credential; the agent hands that capability to a tool broker, which verifies it and performs the action using a credential it holds; any write is staged behind a human approval gate on a phone before it executes; the credential never leaves the trusted brokers.">
<rect width="1180" height="430" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">The broker pattern: ask, never hold</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the agent can only ask &#8212; every credential stays behind the brokers, every write waits on a human</text>
<!-- trust boundary: untrusted (left) vs trusted (right) -->
<rect x="36" y="196" width="230" height="164" rx="16" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="54" y="218" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">untrusted executor</text>
<rect x="298" y="196" width="846" height="164" rx="16" fill="#0c8fce" fill-opacity="0.04" stroke="#0c8fce" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="316" y="218" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#0c8fce">trusted tier &#8212; holds every credential</text>
<!-- agent box -->
<rect x="56" y="236" width="188" height="100" rx="14" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="150" y="272" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15.5" font-weight="700" fill="#c026d3">agent</text>
<text x="150" y="298" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">sandboxed &#183; assume compromised</text>
<text x="150" y="319" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">holds a capability, not a key</text>
<!-- credential broker -->
<rect x="320" y="236" width="196" height="100" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="418" y="266" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">credential broker</text>
<text x="418" y="290" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">verifies the sandbox identity</text>
<text x="418" y="308" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">mints a scoped, one-time,</text>
<text x="418" y="324" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">expiring capability</text>
<!-- tool broker -->
<rect x="598" y="236" width="196" height="100" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="696" y="266" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">tool broker</text>
<text x="696" y="290" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">checks signature &#183; scope &#183; nonce</text>
<text x="696" y="308" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">runs the tool with a credential</text>
<text x="696" y="324" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">it holds &#8212; never the agent</text>
<!-- human approval gate -->
<rect x="876" y="236" width="196" height="100" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="974" y="266" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0e1726">human approval</text>
<text x="974" y="290" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">every write is staged, not run</text>
<text x="974" y="308" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">one-time token &#183; fail-closed</text>
<text x="974" y="324" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">timeout = denied</text>
<!-- arrows -->
<path d="M244 286 H314" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M304 280 L314 286 M304 292 L314 286" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M516 286 H592" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M582 280 L592 286 M582 292 L592 286" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M794 286 H870" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<path d="M860 280 L870 286 M860 292 L870 286" fill="none" stroke="#64748b" stroke-width="2.2" stroke-linecap="round"></path>
<!-- arrow labels -->
<rect x="253" y="255" width="52" height="19" rx="9.5" fill="#ffffff"></rect>
<text x="279" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">asks</text>
<rect x="509" y="255" width="90" height="19" rx="9.5" fill="#ffffff"></rect>
<text x="554" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">capability</text>
<rect x="786" y="255" width="78" height="19" rx="9.5" fill="#ffffff"></rect>
<text x="825" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">stages write</text>
<!-- footer takeaway -->
<text x="60" y="398" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#64748b">The agent can only ask. Every credential, every write-authority, every scope decision lives at the broker &#8212; a compromised agent's worst case is a denied request.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="450" viewBox="0 0 1180 450" role="img" aria-label="cert-manager writes the ACME challenge TXT record to Cloudflare, where it goes live on the public internet and Let's Encrypt validates it successfully. But its own self-check resolves through the cluster's split-horizon DNS — the internal Pi-hole that owns the zone and has no such record — so it loops forever on not-yet-propagated. The fix points the self-check at public resolvers, the same vantage the CA uses.">
<rect width="1180" height="450" fill="#ffffff"></rect>
<defs>
<marker id="g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#0c8fce"></path></marker>
<marker id="r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#c026d3"></path></marker>
<marker id="v" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="#7c3aed"></path></marker>
</defs>
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">The proof is out there &#8212; the self-check looks inside</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">a check is only worth its vantage point: verify from where the judge stands, not from where you live</text>
<!-- cert-manager -->
<rect x="52" y="176" width="196" height="110" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="150" y="212" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0e1726">cert-manager</text>
<text x="150" y="238" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">writes the TXT, then</text>
<text x="150" y="256" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">self-checks before</text>
<text x="150" y="274" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">calling the CA</text>
<!-- write to Cloudflare (works) -->
<path d="M248 200 H432" fill="none" stroke="#0c8fce" stroke-width="2.4" marker-end="url(#g)"></path>
<text x="340" y="190" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0c8fce">write _acme-challenge TXT</text>
<rect x="440" y="150" width="220" height="96" rx="14" fill="#0c8fce" fill-opacity="0.05" stroke="#0c8fce" stroke-width="2"></rect>
<text x="550" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0c8fce">Cloudflare (public)</text>
<text x="550" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">record is LIVE</text>
<text x="550" y="226" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">dig @1.1.1.1 returns the token</text>
<!-- Let's Encrypt validates from public -->
<rect x="760" y="150" width="200" height="96" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="860" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#7c3aed">Let&#8217;s Encrypt</text>
<text x="860" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">validates via public DNS</text>
<text x="860" y="226" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">would pass right now</text>
<path d="M660 198 H752" fill="none" stroke="#7c3aed" stroke-width="2" marker-end="url(#v)"></path>
<!-- the broken self-check: through the Pi-hole (magenta) -->
<rect x="440" y="300" width="220" height="100" rx="14" fill="#c026d3" fill-opacity="0.05" stroke="#c026d3" stroke-width="2"></rect>
<text x="550" y="332" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#c026d3">internal Pi-hole</text>
<text x="550" y="356" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">owns bztmon.org internally</text>
<text x="550" y="376" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">no such TXT &#8594; returns nothing</text>
<path d="M150 286 C150 350 300 360 432 356" fill="none" stroke="#c026d3" stroke-width="2.4" marker-end="url(#r)"></path>
<text x="290" y="380" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">self-check (cluster DNS)</text>
<path d="M470 360 C428 348 424 322 448 310" fill="none" stroke="#c026d3" stroke-width="2" stroke-dasharray="5 5" marker-end="url(#r)"></path>
<text x="550" y="416" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">loops forever: &#8220;not yet propagated&#8221;</text>
<!-- the fix -->
<rect x="760" y="300" width="360" height="100" rx="14" fill="#7c3aed" fill-opacity="0.05" stroke="#7c3aed" stroke-width="2"></rect>
<text x="784" y="330" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" font-weight="700" fill="#7c3aed">the fix &#8212; move the observer, not the record</text>
<text x="784" y="356" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">dns01RecursiveNameservers: 1.1.1.1,8.8.8.8</text>
<text x="784" y="376" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">dns01RecursiveNameserversOnly: true</text>
<path d="M660 350 C700 350 720 350 752 350" fill="none" stroke="#7c3aed" stroke-width="2" stroke-dasharray="5 5" marker-end="url(#v)"></path>
<text x="60" y="430" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" fill="#64748b">check the postbox the postman collects from &#8212; not the mail slot in your own hallway</text>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

+72
View File
@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="470" viewBox="0 0 1180 470" role="img" aria-label="A browser and a phone reach a code-server pod only through an Authentik proxy that redirects every path to login. The pod holds cloned repos, its own git key and its own logins on its own volume, and explicitly no age key. Its egress is limited to DNS, gitea SSH and 443. The bastion holding the age key, admin kubeconfigs and SSH keys sits outside the frame, unreachable from the workbench.">
<rect width="1180" height="470" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">A clone of the brain, never the keys</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">one guarded door to a room of photocopies &#8212; the originals never enter the frame</text>
<!-- clients -->
<rect x="60" y="150" width="150" height="60" rx="12" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="135" y="177" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" font-weight="700" fill="#0e1726">browser</text>
<text x="135" y="196" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">couch &#183; LAN</text>
<rect x="60" y="230" width="150" height="60" rx="12" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="135" y="257" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" font-weight="700" fill="#0e1726">phone</text>
<text x="135" y="276" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">mesh VPN</text>
<!-- arrows to proxy -->
<g stroke="#0c8fce" stroke-width="2" fill="none">
<line x1="210" y1="180" x2="285" y2="208"></line>
<line x1="210" y1="260" x2="285" y2="232"></line>
<polygon points="283,201 296,214 279,216" fill="#0c8fce" stroke="none"></polygon>
</g>
<!-- authentik proxy -->
<rect x="296" y="180" width="210" height="80" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="2.2"></rect>
<text x="401" y="210" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0c8fce">Authentik proxy</text>
<text x="401" y="232" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">every path 302s to login</text>
<text x="401" y="249" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">no carve-outs &#183; WebSockets too</text>
<!-- arrow to pod -->
<g stroke="#0c8fce" stroke-width="2" fill="none">
<line x1="506" y1="220" x2="566" y2="220"></line>
<polygon points="566,214 578,220 566,226" fill="#0c8fce" stroke="none"></polygon>
</g>
<!-- code-server pod -->
<rect x="578" y="130" width="300" height="196" rx="16" fill="#7c3aed" fill-opacity="0.05" stroke="#7c3aed" stroke-width="2.2"></rect>
<text x="728" y="160" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#7c3aed">code-server pod</text>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="604" y="190">own 20Gi volume:</text>
<text x="604" y="212">&#9679; cloned repos (photocopies)</text>
<text x="604" y="234">&#9679; own git key &#8212; revocable alone</text>
<text x="604" y="256">&#9679; own logins, warm</text>
</g>
<text x="604" y="288" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" font-weight="700" fill="#c026d3">&#10007; NO age key &#8212; blobs stay ciphertext</text>
<text x="604" y="310" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="10.5" fill="#64748b">default-deny floor beneath it all</text>
<!-- egress -->
<g stroke="#7c3aed" stroke-width="1.8" fill="none">
<line x1="878" y1="228" x2="938" y2="228"></line>
<polygon points="938,222 950,228 938,234" fill="#7c3aed" stroke="none"></polygon>
</g>
<rect x="950" y="180" width="170" height="96" rx="12" fill="#ffffff" stroke="#7c3aed" stroke-width="1.6"></rect>
<text x="1035" y="206" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#0e1726">egress, complete list</text>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">
<text x="1035" y="228" text-anchor="middle">DNS</text>
<text x="1035" y="246" text-anchor="middle">gitea SSH</text>
<text x="1035" y="264" text-anchor="middle">443 (APIs)</text>
</g>
<!-- the bastion, outside the frame -->
<rect x="296" y="360" width="582" height="70" rx="14" fill="#0e1726" fill-opacity="0.03" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="8 7"></rect>
<text x="587" y="388" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" font-weight="700" fill="#c026d3">the bastion &#8212; outside the frame, unreachable from the workbench</text>
<text x="587" y="412" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">age key &#183; admin kubeconfigs &#183; SSH keys &#8212; the originals, in the vault</text>
<!-- no-path marker between pod and bastion -->
<line x1="728" y1="326" x2="728" y2="360" stroke="#c026d3" stroke-width="2" stroke-dasharray="3 5"></line>
<text x="748" y="348" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#c026d3">no route exists</text>
<!-- footer -->
<text x="60" y="452" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" fill="#64748b">worst case, fully stolen session: a loud, attributed git push &#8212; revertible &#8212; never silent possession of the estate</text>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

+68
View File
@@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="500" viewBox="0 0 1180 500" role="img" aria-label="Before and after the Cilium landing on one cluster: five separately versioned components - flannel, kube-proxy, MetalLB, Traefik, and a set of authored but unenforced network policies - collapse into a single Cilium engine shipped inside the Talos machine configuration. The transition is drawn as deletions, not installs.">
<rect width="1180" height="500" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Landed by deletion: five rooflines become one</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the same cluster before and after &#8212; the arrows are strike-throughs, not installs</text>
<!-- BEFORE column -->
<text x="70" y="112" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#64748b">BEFORE &#8212; five versions to hold</text>
<!-- flannel -->
<rect x="60" y="128" width="330" height="52" rx="12" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="80" y="150" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">flannel</text>
<text x="80" y="169" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">CNI &#183; pod network &#183; its own version</text>
<line x1="52" y1="154" x2="398" y2="154" stroke="#c026d3" stroke-width="2.4" opacity="0.55"></line>
<!-- kube-proxy -->
<rect x="60" y="192" width="330" height="52" rx="12" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="80" y="214" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">kube-proxy</text>
<text x="80" y="233" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">service path &#183; rewrites every service address</text>
<line x1="52" y1="218" x2="398" y2="218" stroke="#c026d3" stroke-width="2.4" opacity="0.55"></line>
<!-- MetalLB -->
<rect x="60" y="256" width="330" height="52" rx="12" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="80" y="278" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">MetalLB</text>
<text x="80" y="297" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">VIP announcement &#183; answers ARP on the LAN</text>
<line x1="52" y1="282" x2="398" y2="282" stroke="#c026d3" stroke-width="2.4" opacity="0.55"></line>
<!-- Traefik / ingress-nginx -->
<rect x="60" y="320" width="330" height="52" rx="12" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="80" y="342" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">Traefik <tspan font-size="11" fill="#64748b">(atop ingress-nginx's old IP)</tspan></text>
<text x="80" y="361" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">HTTP termination &#183; the fossil hop</text>
<line x1="52" y1="346" x2="398" y2="346" stroke="#c026d3" stroke-width="2.4" opacity="0.55"></line>
<!-- inert policies -->
<rect x="60" y="384" width="330" height="52" rx="12" fill="#ffffff" stroke="#64748b" stroke-width="1.6" stroke-dasharray="6 5"></rect>
<text x="80" y="406" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#64748b">NetworkPolicies</text>
<text x="80" y="425" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">authored, committed &#8212; never enforced</text>
<!-- transition arrows -->
<g stroke="#64748b" stroke-width="2" fill="none">
<line x1="420" y1="282" x2="560" y2="282"></line>
<polygon points="560,276 572,282 560,288" fill="#64748b" stroke="none"></polygon>
</g>
<text x="496" y="268" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#64748b">delete, delete,</text>
<text x="496" y="302" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#64748b">delete, delete</text>
<!-- AFTER: Talos machine document holding one Cilium engine -->
<text x="600" y="112" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0c8fce">AFTER &#8212; one engine, in the machine document</text>
<rect x="590" y="128" width="530" height="308" rx="16" fill="#7c3aed" fill-opacity="0.04" stroke="#7c3aed" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="608" y="152" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#7c3aed">Talos machine configuration &#8212; inline manifest, no Helm release</text>
<rect x="622" y="170" width="466" height="242" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="2"></rect>
<text x="855" y="200" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="17" font-weight="700" fill="#0c8fce">Cilium 1.19</text>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#0e1726">
<text x="655" y="232">&#9679; CNI &#8212; pod network</text>
<text x="655" y="258">&#9679; eBPF datapath &#8212; kube-proxy's old job</text>
<text x="655" y="284">&#9679; LB-IPAM + L2 &#8212; MetalLB's old VIPs, kept</text>
<text x="655" y="310">&#9679; Gateway API &#8212; ingress, where absorbed</text>
<text x="655" y="336">&#9679; NetworkPolicy &#8212; now actually enforced</text>
<text x="655" y="362">&#9679; Hubble &#8212; the flows, visible at last</text>
</g>
<text x="655" y="394" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">one version to pin &#183; one changelog &#183; one way to be paged</text>
<!-- footer -->
<text x="60" y="472" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" fill="#64748b">fleet result: kube-proxy deleted &#183; MetalLB extinct &#183; policies enforced &#183; zero Helm release secrets &#8212; same VIPs throughout</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+77
View File
@@ -0,0 +1,77 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="480" viewBox="0 0 1180 480" role="img" aria-label="NetBox holds the device inventory as source of truth; Unimus reads it and drives scheduled config backup, diff-tracked change and bulk push across the AU, NZ and PNG switch fleet. Device credentials come from a central vault with rotation, never from scripts. Every backed-up config is a restore point, giving the network a real DR path.">
<rect width="1180" height="480" fill="#ffffff"></rect>
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">One source of truth, one pane, three regions</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the automation acts on an accurate model of the fleet &#8212; not on tribal knowledge</text>
<!-- SOURCE OF TRUTH -->
<rect x="60" y="150" width="228" height="150" rx="14" fill="#7c3aed" fill-opacity="0.05" stroke="#7c3aed" stroke-width="2.2"></rect>
<text x="174" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="16" font-weight="700" fill="#7c3aed">NetBox</text>
<text x="174" y="204" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">IPAM / source of truth</text>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="84" y="234">device inventory</text>
<text x="84" y="256">site + role model</text>
<text x="84" y="278">addressing</text>
</g>
<g stroke="#7c3aed" stroke-width="2.2" fill="none">
<line x1="288" y1="225" x2="356" y2="225"></line>
<polygon points="356,219 368,225 356,231" fill="#7c3aed" stroke="none"></polygon>
</g>
<text x="322" y="214" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">drives</text>
<!-- UNIMUS -->
<rect x="368" y="128" width="268" height="194" rx="14" fill="#0c8fce" fill-opacity="0.06" stroke="#0c8fce" stroke-width="2.4"></rect>
<text x="502" y="160" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="17" font-weight="700" fill="#0c8fce">Unimus</text>
<text x="502" y="181" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">vendor-agnostic NCM</text>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#0e1726">
<text x="394" y="212">&#9679; scheduled config backup</text>
<text x="394" y="238">&#9679; change tracking + diffs</text>
<text x="394" y="264">&#9679; mass / bulk config push</text>
</g>
<text x="394" y="296" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">minutes, not box-by-box days</text>
<!-- VAULT feeding Unimus -->
<rect x="368" y="356" width="268" height="76" rx="12" fill="#ffffff" stroke="#c026d3" stroke-width="2.2"></rect>
<text x="502" y="382" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" font-weight="700" fill="#c026d3">central credential vault</text>
<text x="502" y="403" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">scoped &#183; rotated</text>
<text x="502" y="421" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">never in a script, never by hand</text>
<g stroke="#c026d3" stroke-width="2" fill="none">
<line x1="502" y1="356" x2="502" y2="334"></line>
<polygon points="496,336 502,324 508,336" fill="#c026d3" stroke="none"></polygon>
</g>
<!-- FLEET -->
<g stroke="#0c8fce" stroke-width="2.2" fill="none">
<line x1="636" y1="225" x2="704" y2="225"></line>
<polygon points="704,219 716,225 704,231" fill="#0c8fce" stroke="none"></polygon>
</g>
<rect x="716" y="120" width="404" height="210" rx="16" fill="#0c8fce" fill-opacity="0.03" stroke="#0c8fce" stroke-width="1.8" stroke-dasharray="7 6"></rect>
<text x="918" y="148" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#0c8fce">multi-vendor switch fleet &#8212; one pane</text>
<rect x="742" y="168" width="112" height="66" rx="12" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="798" y="196" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">AU</text>
<text x="798" y="218" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#64748b">sites</text>
<rect x="862" y="168" width="112" height="66" rx="12" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="918" y="196" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">NZ</text>
<text x="918" y="218" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#64748b">sites</text>
<rect x="982" y="168" width="112" height="66" rx="12" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="1038" y="196" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">PNG</text>
<text x="1038" y="218" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#64748b">sites</text>
<text x="918" y="272" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">mixed vendors &#8212; the tooling is not tied to one OS</text>
<text x="918" y="300" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">live production &#8212; changed without taking sites down</text>
<!-- DR return path -->
<g stroke="#7c3aed" stroke-width="2" fill="none" stroke-dasharray="6 5">
<path d="M 918 330 C 918 392, 760 402, 700 402"></path>
<polygon points="702,396 690,402 702,408" fill="#7c3aed" stroke="none"></polygon>
</g>
<text x="812" y="428" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#7c3aed">every backup is a restore point</text>
<text x="812" y="446" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">config DR the fleet never had before</text>
<text x="60" y="466" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" fill="#64748b">community tooling, productionised under a corporate-supported licence</text>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

+81
View File
@@ -0,0 +1,81 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="470" viewBox="0 0 1180 470" role="img" aria-label="A traditional server with many interactive doors accumulating drift, contrasted with a sealed Talos node whose only interface is a mutual-TLS API accepting a versioned machine-config document and answering typed reads.">
<rect width="1180" height="470" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Many doors and drift, versus one API and a document</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">every interactive door is a place where reality and the record can quietly diverge</text>
<defs>
<marker id="arr3" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#64748b"></path>
</marker>
<marker id="arrm3" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#c026d3"></path>
</marker>
<marker id="arrc3" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#0c8fce"></path>
</marker>
</defs>
<!-- ===== left: the traditional box ===== -->
<rect x="60" y="110" width="440" height="290" rx="16" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="78" y="136" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">a server that accepts hands</text>
<!-- the box itself -->
<rect x="150" y="200" width="260" height="150" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="280" y="268" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0e1726">the machine</text>
<text x="280" y="292" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">state: whatever the last pair</text>
<text x="280" y="308" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">of hands left behind</text>
<!-- doors (arrows in) -->
<text x="88" y="188" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">sshd :22</text>
<line x1="130" y1="184" x2="196" y2="216" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<text x="72" y="248" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">console login</text>
<line x1="140" y1="252" x2="188" y2="262" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<text x="66" y="312" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">package manager</text>
<line x1="140" y1="316" x2="188" y2="306" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<text x="110" y="382" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">"just quickly fix it"</text>
<line x1="220" y1="374" x2="252" y2="354" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<!-- drift callout -->
<text x="424" y="228" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#c026d3">drift:</text>
<text x="424" y="246" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">unrecorded &#183;</text>
<text x="424" y="262" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">load-bearing</text>
<text x="424" y="278" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">by next month</text>
<!-- ===== right: the sealed node ===== -->
<rect x="560" y="110" width="584" height="290" rx="16" fill="#0c8fce" fill-opacity="0.04" stroke="#0c8fce" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="578" y="136" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#0c8fce">a sealed node &#8212; the API is the only interface</text>
<!-- git document -->
<rect x="588" y="200" width="180" height="110" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="678" y="232" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#7c3aed">the document</text>
<text x="678" y="254" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">machine config in git</text>
<text x="678" y="270" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">disks &#183; NICs &#183; mirrors &#183; CNI</text>
<text x="678" y="286" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">secrets substituted at render</text>
<!-- API keyhole -->
<rect x="828" y="214" width="120" height="82" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="888" y="246" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0c8fce">the API</text>
<text x="888" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">mTLS gRPC</text>
<text x="888" y="284" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">no shell behind it</text>
<!-- the node -->
<rect x="1008" y="200" width="112" height="110" rx="14" fill="#0e1726" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="1064" y="252" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#ffffff">the node</text>
<text x="1064" y="274" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#7dd3fc">state = document</text>
<!-- flows -->
<line x1="768" y1="240" x2="824" y2="240" stroke="#7c3aed" stroke-width="2" marker-end="url(#arr3)"></line>
<text x="796" y="228" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#7c3aed">apply</text>
<line x1="948" y1="240" x2="1004" y2="240" stroke="#0c8fce" stroke-width="2" marker-end="url(#arrc3)"></line>
<!-- typed reads coming back -->
<path d="M 1008 290 L 954 290" fill="none" stroke="#0c8fce" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrc3)"></path>
<text x="700" y="342" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">typed reads out through the same keyhole:</text>
<text x="700" y="360" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">/proc/uptime (prove the reboot) &#183; logs &#183; image pull (prove the credential)</text>
<text x="700" y="378" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">dry-run diff: what changes, and whether it costs a reboot</text>
<!-- footnote -->
<text x="60" y="442" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" fill="#64748b">Rebuild = reprint the document. Drift has nowhere to live, because nobody can log in to create it.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="430" viewBox="0 0 1180 430" role="img" aria-label="The observe-first rollout loop: watch real flows with Hubble, write the allowlist from that evidence, apply it out-of-band where rollback is one delete, verify it is enforcing with zero legitimate drops, and only then commit it to git where the reconciler defends it.">
<rect width="1180" height="430" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Observe first, deny second: the loop</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">every rule points at a flow you watched happen &#8212; and the policy only reaches git after it has proven itself</text>
<!-- arrow marker defs -->
<defs>
<marker id="arr" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#64748b"></path>
</marker>
<marker id="arrc" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#0c8fce"></path>
</marker>
</defs>
<!-- stage 1: observe -->
<rect x="56" y="150" width="188" height="110" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="150" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0c8fce">observe</text>
<text x="150" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">Hubble &#183; real flows</text>
<text x="150" y="222" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">source &#183; destination &#183; port</text>
<text x="150" y="238" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">before any policy exists</text>
<!-- stage 2: write -->
<rect x="292" y="150" width="188" height="110" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="386" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#7c3aed">write from evidence</text>
<text x="386" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">zero-peer default-deny</text>
<text x="386" y="222" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">+ allows for watched flows</text>
<text x="386" y="238" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">DNS by label &#183; host probes &#183; API</text>
<!-- stage 3: apply out-of-band (dashed boundary = temporary state) -->
<rect x="528" y="138" width="212" height="134" rx="16" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="634" y="176" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#c026d3">apply out-of-band</text>
<text x="634" y="200" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">kubectl, not git</text>
<text x="634" y="216" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">the reconciler can't fight</text>
<text x="634" y="232" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">what it can't see</text>
<text x="634" y="256" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">rollback = one delete</text>
<!-- stage 4: verify -->
<rect x="788" y="150" width="188" height="110" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="882" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0c8fce">verify both ways</text>
<text x="882" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">status: Valid=True</text>
<text x="882" y="222" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">forbidden flow drops</text>
<text x="882" y="238" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">zero legitimate drops</text>
<!-- stage 5: commit -->
<rect x="1024" y="150" width="120" height="110" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="1084" y="188" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0e1726">commit</text>
<text x="1084" y="212" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">GitOps adopts</text>
<text x="1084" y="228" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">+ defends it</text>
<!-- forward arrows -->
<line x1="244" y1="205" x2="288" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<line x1="480" y1="205" x2="524" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<line x1="740" y1="205" x2="784" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<line x1="976" y1="205" x2="1020" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<!-- loop-back: verify failure returns to observe -->
<path d="M 882 264 L 882 330 L 150 330 L 150 268" fill="none" stroke="#0c8fce" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrc)"></path>
<text x="516" y="322" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">a legitimate drop found? back to watching &#8212; the fence was wrong, not the animal</text>
<!-- footnote -->
<text x="60" y="396" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" fill="#64748b">After the commit, rollback changes shape: git revert &#8212; self-heal now restores the policy against any hand-delete, exactly as designed.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="480" viewBox="0 0 1180 480" role="img" aria-label="One hostname, two audiences. Human browsers converge on a single identity-provider proxy outpost which fronts the registry web UI and the browser IDE, each bound to the same outpost. Machine clients - node container runtimes and CI - split off at the gateway and hit the registry's v2 path directly with their own scoped read-only credential, never touching the SSO layer.">
<rect width="1180" height="480" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Route by audience, not by app</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">one hostname &#8212; the gateway splits people from machines before auth is even asked</text>
<!-- the gateway split -->
<rect x="80" y="200" width="170" height="80" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="2"></rect>
<text x="165" y="234" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0e1726">gateway</text>
<text x="165" y="256" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">one hostname, split routes</text>
<!-- human path (cyan, upper) -->
<g stroke="#0c8fce" stroke-width="2.4" fill="none">
<path d="M 250 220 C 310 190, 330 170, 388 152"></path>
<polygon points="384,146 400,148 388,161" fill="#0c8fce" stroke="none"></polygon>
</g>
<text x="300" y="164" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" font-weight="700" fill="#0c8fce">catch-all &#8594;</text>
<rect x="400" y="110" width="230" height="86" rx="14" fill="#0c8fce" fill-opacity="0.06" stroke="#0c8fce" stroke-width="2.4"></rect>
<text x="515" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0c8fce">proxy outpost</text>
<text x="515" y="162" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">the doorman &#8212; one instance</text>
<text x="515" y="180" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">every human path signs in here</text>
<!-- apps behind the doorman -->
<g stroke="#0c8fce" stroke-width="2" fill="none">
<line x1="630" y1="140" x2="716" y2="128"></line>
<polygon points="714,122 728,126 716,135" fill="#0c8fce" stroke="none"></polygon>
<line x1="630" y1="170" x2="716" y2="184"></line>
<polygon points="716,177 728,186 714,190" fill="#0c8fce" stroke="none"></polygon>
</g>
<rect x="730" y="100" width="220" height="56" rx="12" fill="#7c3aed" fill-opacity="0.05" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="840" y="124" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#7c3aed">registry web UI</text>
<text x="840" y="144" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">a nameplate at the desk</text>
<rect x="730" y="168" width="220" height="56" rx="12" fill="#7c3aed" fill-opacity="0.05" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="840" y="192" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#7c3aed">browser IDE</text>
<text x="840" y="212" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">WebSockets included &#8212; no carve-outs</text>
<!-- humans arriving -->
<rect x="80" y="110" width="120" height="52" rx="12" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="140" y="133" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#0c8fce">people</text>
<text x="140" y="152" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#64748b">interactive &#183; phishable</text>
<g stroke="#0c8fce" stroke-width="2" fill="none">
<line x1="165" y1="162" x2="165" y2="200"></line>
<polygon points="159,198 165,210 171,198" fill="#0c8fce" stroke="none"></polygon>
</g>
<!-- machine path (magenta, lower) -->
<rect x="80" y="330" width="120" height="52" rx="12" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="140" y="353" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#c026d3">machines</text>
<text x="140" y="372" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#64748b">runtimes &#183; headless</text>
<g stroke="#c026d3" stroke-width="2" fill="none">
<line x1="165" y1="330" x2="165" y2="292"></line>
<polygon points="159,294 165,282 171,294" fill="#c026d3" stroke="none"></polygon>
</g>
<g stroke="#c026d3" stroke-width="2.4" fill="none">
<path d="M 250 262 C 340 300, 420 322, 508 336"></path>
<polygon points="504,329 520,338 506,349" fill="#c026d3" stroke="none"></polygon>
</g>
<text x="330" y="318" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" font-weight="700" fill="#c026d3">/v2 &#8594; direct</text>
<rect x="520" y="310" width="250" height="86" rx="14" fill="#c026d3" fill-opacity="0.05" stroke="#c026d3" stroke-width="2.2"></rect>
<text x="645" y="340" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#c026d3">registry /v2 &#8212; the dock</text>
<text x="645" y="362" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">scoped read-only credential</text>
<text x="645" y="380" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">in each node's registry config</text>
<!-- never-crosses note -->
<line x1="400" y1="240" x2="950" y2="240" stroke="#64748b" stroke-width="1.4" stroke-dasharray="4 6"></line>
<text x="675" y="258" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">the two paths never cross &#8212; neither door weakens for the other's traffic</text>
<!-- native-OIDC aside -->
<rect x="980" y="310" width="140" height="86" rx="12" fill="#ffffff" stroke="#64748b" stroke-width="1.4" stroke-dasharray="5 5"></rect>
<text x="1050" y="338" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#64748b">native OIDC apps</text>
<text x="1050" y="358" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#64748b">wire directly &#8212;</text>
<text x="1050" y="374" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#64748b">no outpost needed</text>
<!-- footer -->
<text x="60" y="452" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" fill="#64748b">app N+1 behind the doorman costs a provider object and a route &#8212; a nameplate at the desk, never a second desk</text>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="470" viewBox="0 0 1180 470" role="img" aria-label="One required value in the cluster's values file feeds four rendered artefacts: the application config, the network attachment definition, the egress policy and the secondary-interface policy. Because every consumer renders from the same field, the four copies cannot disagree - there is nothing to update twice. Underneath, the other half of the pattern: the schema marks the field required, so a cluster that has not supplied the value fails at template time, in the pipeline, instead of shipping enforcement pointed at nothing.">
<rect width="1180" height="470" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">One source, four renders</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">divergence becomes something the tooling can no longer express</text>
<!-- source -->
<rect x="80" y="160" width="240" height="90" rx="14" fill="#0c8fce" fill-opacity="0.07" stroke="#0c8fce" stroke-width="3"></rect>
<text x="200" y="196" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12.5" fill="#0c8fce" font-weight="700">cluster values</text>
<text x="200" y="218" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">device address &#183; one field</text>
<text x="200" y="272" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">lives once &#183; edited once</text>
<!-- fan lines -->
<g stroke="#64748b" stroke-width="1.5" fill="none">
<path d="M 320 205 C 420 205 460 105 560 105"></path>
<path d="M 320 205 C 420 205 460 172 560 172"></path>
<path d="M 320 205 C 420 205 460 239 560 239"></path>
<path d="M 320 205 C 420 205 460 306 560 306"></path>
</g>
<!-- consumers -->
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">
<rect x="560" y="82" width="300" height="46" rx="10" fill="#7c3aed" fill-opacity="0.04" stroke="#7c3aed" stroke-width="1.5"></rect>
<text x="580" y="110">application config</text>
<rect x="560" y="149" width="300" height="46" rx="10" fill="#7c3aed" fill-opacity="0.04" stroke="#7c3aed" stroke-width="1.5"></rect>
<text x="580" y="177">network attachment</text>
<rect x="560" y="216" width="300" height="46" rx="10" fill="#7c3aed" fill-opacity="0.04" stroke="#7c3aed" stroke-width="1.5"></rect>
<text x="580" y="244">egress rule</text>
<rect x="560" y="283" width="300" height="46" rx="10" fill="#7c3aed" fill-opacity="0.04" stroke="#7c3aed" stroke-width="1.5"></rect>
<text x="580" y="311">secondary-interface policy</text>
</g>
<g font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">
<text x="890" y="110">what the workload calls</text>
<text x="890" y="177">the address it answers on</text>
<text x="890" y="244">what the traffic may do</text>
<text x="890" y="311">same, for the second leg</text>
</g>
<text x="710" y="352" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" font-weight="700" fill="#7c3aed">never independent copies &#8212; they render, they don't remember</text>
<!-- required strip -->
<rect x="80" y="378" width="1020" height="58" rx="12" fill="#c026d3" fill-opacity="0.05" stroke="#c026d3" stroke-width="1.5"></rect>
<text x="100" y="402" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#c026d3">absence fails loudly</text>
<text x="100" y="422" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#0e1726">field required in the schema &#8594; a cluster without it fails at template time, in the pipeline &#8212; not months later as policy pointed at nothing</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+79
View File
@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="520" viewBox="0 0 1180 520" role="img" aria-label="The probation ladder for downloaded models, left to right. Arrive: the artefact is more than weights - tokeniser code, an executable chat template, a loader gated by trust_remote_code - pinned by digest with its runtime. Probation tier: a Sandbox resource wraps a pod whose kata runtime class boots a per-pod KVM microVM with its own guest kernel, inside a zero-peer default-deny namespace holding zero credentials; the broker tier above holds all real keys. Observe: real workloads through the harness build a record of egress and behaviour against the written declaration. Gate: when the record matches the declaration over time the model promotes to the standard serving tier; unproven models stay in probation indefinitely. A hardware boundary separates the container from the node kernel for the whole supervised shift.">
<rect width="1180" height="520" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">The probation ladder</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">a new model is a new hire &#8212; promotion is earned on the record, never granted on arrival</text>
<!-- spine -->
<line x1="80" y1="105" x2="1100" y2="105" stroke="#64748b" stroke-width="1.5"></line>
<circle cx="200" cy="105" r="5" fill="#7c3aed"></circle>
<circle cx="530" cy="105" r="5" fill="#0c8fce"></circle>
<circle cx="830" cy="105" r="5" fill="#64748b"></circle>
<circle cx="1020" cy="105" r="5" fill="#c026d3"></circle>
<!-- ARRIVE -->
<text x="200" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#7c3aed">ARRIVE</text>
<rect x="80" y="152" width="240" height="230" rx="14" fill="#7c3aed" fill-opacity="0.04" stroke="#7c3aed" stroke-width="2"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="100" y="182">not just weights:</text>
<text x="100" y="204">+ tokeniser code</text>
<text x="100" y="224">+ executable chat template</text>
<text x="100" y="244">+ loader (trust_remote_code)</text>
<text x="100" y="264">+ sometimes its own runtime</text>
</g>
<text x="100" y="300" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" font-weight="700" fill="#7c3aed">pinned by digest,</text>
<text x="100" y="318" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" font-weight="700" fill="#7c3aed">artefact + runtime</text>
<text x="100" y="352" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">the CV: an account name and</text>
<text x="100" y="368" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">a download count &#8212; uncheckable</text>
<!-- PROBATION (highlighted) -->
<text x="530" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0c8fce">PROBATION &#8212; the supervised shift</text>
<rect x="360" y="152" width="340" height="230" rx="14" fill="#0c8fce" fill-opacity="0.07" stroke="#0c8fce" stroke-width="3"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="380" y="182">Sandbox CR &#8594; pod &#8594; runtimeClass kata</text>
<text x="380" y="204">&#8594; per-pod KVM microVM,</text>
<text x="392" y="222">its OWN guest kernel</text>
<text x="380" y="248">namespace: default-deny, zero peers</text>
<text x="380" y="268">credentials mounted: none</text>
<text x="380" y="288">egress: only what is declared</text>
<text x="380" y="308">keys: held by the broker tier above</text>
</g>
<line x1="380" y1="326" x2="680" y2="326" stroke="#0c8fce" stroke-width="1.5" stroke-dasharray="6 5"></line>
<text x="380" y="348" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#0c8fce">hardware boundary: an escape lands in a guest kernel,</text>
<text x="380" y="366" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#0c8fce">not on the node</text>
<!-- OBSERVE -->
<text x="830" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#64748b">OBSERVE</text>
<rect x="740" y="152" width="180" height="230" rx="14" fill="#64748b" fill-opacity="0.04" stroke="#64748b" stroke-width="2"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="758" y="182">real workloads</text>
<text x="758" y="202">through the</text>
<text x="758" y="222">harness</text>
<text x="758" y="252">the record:</text>
<text x="758" y="272">egress log vs</text>
<text x="758" y="292">declaration</text>
<text x="758" y="312">behaviour vs</text>
<text x="758" y="332">expectation</text>
</g>
<text x="758" y="366" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">long enough, boring enough</text>
<!-- GATE -->
<text x="1020" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#c026d3">GATE</text>
<rect x="950" y="152" width="150" height="230" rx="14" fill="#c026d3" fill-opacity="0.05" stroke="#c026d3" stroke-width="2"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11">
<text x="968" y="188" fill="#0c8fce">record matches</text>
<text x="968" y="208" fill="#0c8fce">&#8594; PROMOTE to</text>
<text x="980" y="228" fill="#0c8fce">serving tier</text>
<text x="968" y="266" fill="#c026d3">unproven</text>
<text x="968" y="286" fill="#c026d3">&#8594; stays in the</text>
<text x="980" y="306" fill="#c026d3">VM. forever</text>
<text x="980" y="326" fill="#c026d3">if need be</text>
</g>
<text x="968" y="362" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">permanent probation</text>
<text x="968" y="378" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">costs almost nothing</text>
<!-- footer -->
<text x="590" y="452" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#0e1726">trust is a history, not a property &#8212; observed behaviour, under constraint, accumulated until it's boring</text>
<text x="590" y="478" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">the tax (seconds to boot, 350 MiB per microVM, batch-shaped work) isn't a flaw in the ladder &#8212; the tax IS the ladder</text>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

+55
View File
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="440" viewBox="0 0 1180 440" role="img" aria-label="Nightly backups stream from the clusters into a repository on the NAS and every job reports green; the highlighted drill loop pulls a snapshot back out into a scratch pod, verifies files and configs, and produces a pass or fail — the loop out of the vault is the proof, not the data going in.">
<rect width="1180" height="440" fill="#ffffff"></rect>
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#94a3b8"></path>
</marker>
<marker id="arrC" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#0c8fce"></path>
</marker>
</defs>
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">The drill loop is the receipt</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">data flowing in happens every night &#8212; only data pulled back OUT proves the backup is real</text>
<!-- clusters -->
<rect x="56" y="150" width="180" height="84" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="146" y="184" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0e1726">cluster apps</text>
<text x="146" y="208" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">PVCs &#183; dumps &#183; state</text>
<rect x="56" y="250" width="180" height="60" rx="14" fill="#ffffff" stroke="#94a3b8" stroke-width="1.4"></rect>
<text x="146" y="278" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#64748b">nightly CronJob</text>
<text x="146" y="296" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">exit 0 &#8594; green tick</text>
<!-- repository vault -->
<rect x="380" y="150" width="230" height="160" rx="16" fill="#7c3aed" fill-opacity="0.05" stroke="#7c3aed" stroke-width="2"></rect>
<text x="495" y="186" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15.5" font-weight="700" fill="#7c3aed">repository</text>
<text x="495" y="210" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">restic on the NAS &#183; encrypted</text>
<text x="495" y="230" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">key held offline &#8212; never</text>
<text x="495" y="246" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">stored beside the data</text>
<text x="495" y="284" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">a green tick ends here</text>
<!-- backup arrows (routine, grey) -->
<path d="M236 192 H372" fill="none" stroke="#94a3b8" stroke-width="2" marker-end="url(#arr)"></path>
<text x="304" y="182" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">snapshot in</text>
<path d="M236 280 C300 280 320 250 372 236" fill="none" stroke="#94a3b8" stroke-width="2" marker-end="url(#arr)"></path>
<!-- THE DRILL LOOP (highlighted, cyan) -->
<rect x="700" y="150" width="210" height="110" rx="14" fill="#0c8fce" fill-opacity="0.05" stroke="#0c8fce" stroke-width="2.2"></rect>
<text x="805" y="184" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14.5" font-weight="700" fill="#0c8fce">scratch pod</text>
<text x="805" y="208" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">restore into nothing</text>
<text x="805" y="228" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">files pulled &#183; configs read</text>
<text x="805" y="244" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">services prove they start</text>
<rect x="980" y="164" width="140" height="82" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="2"></rect>
<text x="1050" y="199" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="26" font-weight="700" fill="#0c8fce">&#10003;</text>
<text x="1050" y="228" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">pass / fail</text>
<path d="M610 205 H692" fill="none" stroke="#0c8fce" stroke-width="3" marker-end="url(#arrC)"></path>
<text x="651" y="195" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" font-weight="700" fill="#0c8fce">snapshot OUT</text>
<path d="M910 205 H972" fill="none" stroke="#0c8fce" stroke-width="3" marker-end="url(#arrC)"></path>
<path d="M805 260 C805 330 560 330 500 314" fill="none" stroke="#0c8fce" stroke-width="2" stroke-dasharray="6 5" marker-end="url(#arrC)"></path>
<text x="672" y="342" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0c8fce">drill on a schedule &#183; failed drill = real incident</text>
<text x="60" y="404" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" fill="#64748b">the vault is not the proof &#8212; the loop out of it is</text>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

+44
View File
@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="500" viewBox="0 0 1180 500" role="img" aria-label="Two views of the same six days. What the platform saw: the container running, the process alive, the port answering, logs present - every default check passing. What was true: the tunnel the workload exists to use was down, nothing had moved for six days, and the retry backoff made the logs quieter each day. A timeline underneath runs from the failure on day zero to discovery on day six - found by a person asking a question, not by the stack. The fix strip: probe the capability, not the container - the tunnel's own health check gates the workload, so health only reports when traffic genuinely egresses.">
<rect width="1180" height="500" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Running was never the job</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the gap between &#34;the process is running&#34; and &#34;the process is doing its job&#34; is where invisible outages live</text>
<!-- platform view -->
<text x="315" y="112" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0c8fce">WHAT THE PLATFORM SAW</text>
<rect x="70" y="126" width="490" height="170" rx="14" fill="#0c8fce" fill-opacity="0.05" stroke="#0c8fce" stroke-width="2"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">
<text x="100" y="160">container: Running</text>
<text x="100" y="184">process: alive, answers, holds its port</text>
<text x="100" y="208">every default check: passing</text>
<text x="100" y="232">logs: present &#8212; and getting quieter</text>
</g>
<text x="100" y="272" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">entirely satisfied &#183; no reason to think otherwise</text>
<!-- truth view -->
<text x="865" y="112" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#c026d3">WHAT WAS TRUE</text>
<rect x="620" y="126" width="490" height="170" rx="14" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">
<text x="650" y="160">the tunnel: down</text>
<text x="650" y="184">traffic moved: none, for six days</text>
<text x="650" y="208">retry backoff: growing &#8212; the failure</text>
<text x="662" y="230">was quietly obscuring itself</text>
</g>
<text x="650" y="272" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">alive, responsive, and completely useless</text>
<!-- timeline -->
<line x1="90" y1="340" x2="1090" y2="340" stroke="#64748b" stroke-width="1.5"></line>
<circle cx="130" cy="340" r="6" fill="#c026d3"></circle>
<circle cx="1050" cy="340" r="6" fill="#0c8fce"></circle>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5">
<text x="130" y="322" text-anchor="middle" fill="#c026d3">day 0: tunnel dies</text>
<text x="1050" y="322" text-anchor="middle" fill="#0c8fce">day 6: a person asks</text>
</g>
<text x="590" y="366" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">six days of nothing &#8212; discovered by accident, the way every unwatched failure is discovered</text>
<!-- fix strip -->
<rect x="70" y="396" width="1040" height="70" rx="12" fill="#0c8fce" fill-opacity="0.05" stroke="#0c8fce" stroke-width="1.5"></rect>
<text x="90" y="422" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#0c8fce">probe the capability, not the container</text>
<text x="90" y="444" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11.5" fill="#0e1726">the tunnel's own health check gates the workload &#8594; passes only when traffic genuinely egresses &#8594; silent failure becomes a restart, then an alert</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+60
View File
@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="470" viewBox="0 0 1180 470" role="img" aria-label="Three node power states - Ready, off by choice, and parked broken - drawn above one continuous document layer in git holding the machine config, inline CNI, workloads and credentials. Ready and off-by-choice exchange through a power button; parked-broken exits only through a repair. The document layer persists identically beneath all three states.">
<rect width="1180" height="470" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Power is a state; the node is a document</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the states sit on top &#8212; the document layer underneath never changes</text>
<!-- READY -->
<rect x="80" y="110" width="280" height="150" rx="14" fill="#0c8fce" fill-opacity="0.06" stroke="#0c8fce" stroke-width="2.2"></rect>
<text x="220" y="145" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="16" font-weight="700" fill="#0c8fce">READY</text>
<text x="220" y="172" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">lit &#183; workloads reconciled</text>
<text x="220" y="192" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">cave &#183; alfred</text>
<text x="220" y="236" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">the always-on core</text>
<!-- OFF BY CHOICE -->
<rect x="450" y="110" width="280" height="150" rx="14" fill="#0e1726" fill-opacity="0.05" stroke="#0e1726" stroke-width="2"></rect>
<text x="590" y="145" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="16" font-weight="700" fill="#0e1726">OFF BY CHOICE</text>
<text x="590" y="172" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">dark &#183; healthy at shutdown</text>
<text x="590" y="192" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">robin &#183; kate &#183; wgirl</text>
<text x="590" y="236" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">recorded as rest, in the log</text>
<!-- power-button exchange between READY and OFF-BY-CHOICE -->
<g stroke="#0c8fce" stroke-width="2" fill="none">
<line x1="360" y1="165" x2="450" y2="165"></line>
<polygon points="450,159 462,165 450,171" fill="#0c8fce" stroke="none"></polygon>
<line x1="462" y1="205" x2="372" y2="205"></line>
<polygon points="372,199 360,205 372,211" fill="#0c8fce" stroke="none"></polygon>
</g>
<text x="411" y="152" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#0c8fce">talosctl shutdown</text>
<text x="411" y="226" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#0c8fce">one power button</text>
<!-- PARKED BROKEN, set apart -->
<rect x="850" y="110" width="270" height="150" rx="14" fill="#c026d3" fill-opacity="0.05" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="985" y="145" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="16" font-weight="700" fill="#c026d3">PARKED BROKEN</text>
<text x="985" y="172" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">dark &#183; hardware fault</text>
<text x="985" y="192" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">elfastc</text>
<text x="985" y="236" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" font-weight="700" fill="#c026d3">only exit: a screwdriver</text>
<!-- separator emphasising the two kinds of dark -->
<line x1="790" y1="110" x2="790" y2="260" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4 6"></line>
<text x="790" y="98" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">identical from the network &#8212; different in the books</text>
<!-- the document layer -->
<rect x="80" y="300" width="1040" height="110" rx="16" fill="#7c3aed" fill-opacity="0.07" stroke="#7c3aed" stroke-width="2.6"></rect>
<text x="600" y="334" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="16" font-weight="700" fill="#7c3aed">the document layer &#8212; git, continuous beneath every state</text>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12" fill="#0e1726">
<text x="180" y="374">machine config</text>
<text x="420" y="374">CNI inline manifest</text>
<text x="700" y="374">workloads (GitOps)</text>
<text x="950" y="374">credentials (sops)</text>
</g>
<g stroke="#7c3aed" stroke-width="1.6" fill="none" opacity="0.7">
<line x1="220" y1="260" x2="220" y2="300"></line>
<line x1="590" y1="260" x2="590" y2="300"></line>
<line x1="985" y1="260" x2="985" y2="300"></line>
</g>
<!-- footer -->
<text x="60" y="448" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" fill="#64748b">a node boots into exactly the machine its documents describe &#8212; power is just whether the document is currently being read</text>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

+87
View File
@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="470" viewBox="0 0 1180 470" role="img" aria-label="Two paths for the same client packet: with externalTrafficPolicy Cluster the node SNATs the connection so policy sees the un-restrictable world identity; with externalTrafficPolicy Local the client's real address survives to policy evaluation and a precise allow rule can match it.">
<rect width="1180" height="470" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Policies bind to what arrives, not what was sent</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the same client, the same service &#8212; one traffic-policy line decides whether the policy ever meets the sender</text>
<defs>
<marker id="arr2" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#64748b"></path>
</marker>
<marker id="arrm" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#c026d3"></path>
</marker>
<marker id="arrc2" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#0c8fce"></path>
</marker>
</defs>
<!-- ===== top lane: ETP Cluster ===== -->
<rect x="36" y="100" width="1108" height="150" rx="16" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="54" y="124" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">externalTrafficPolicy: Cluster &#8212; the default, and the depot stamp</text>
<!-- client -->
<rect x="66" y="146" width="170" height="84" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="151" y="178" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">client</text>
<text x="151" y="200" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">src 10.0.11.42</text>
<text x="151" y="217" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">an honest return address</text>
<!-- LB VIP -->
<rect x="306" y="146" width="150" height="84" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="381" y="184" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#7c3aed">LB VIP</text>
<text x="381" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">any node accepts</text>
<!-- SNAT -->
<rect x="526" y="146" width="200" height="84" rx="14" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="626" y="176" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#c026d3">node SNAT</text>
<text x="626" y="198" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">src &#8594; the node's own</text>
<text x="626" y="215" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">stamped over in transit</text>
<!-- policy sees world -->
<rect x="796" y="146" width="200" height="84" rx="14" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="896" y="176" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#c026d3">policy evaluates</text>
<text x="896" y="198" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#c026d3">identity: world</text>
<text x="896" y="215" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">the sender is gone</text>
<text x="1052" y="182" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">no rule can</text>
<text x="1052" y="200" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">name the client</text>
<line x1="236" y1="188" x2="302" y2="188" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="456" y1="188" x2="522" y2="188" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="726" y1="188" x2="792" y2="188" stroke="#c026d3" stroke-width="2" marker-end="url(#arrm)"></line>
<!-- ===== bottom lane: ETP Local ===== -->
<rect x="36" y="272" width="1108" height="150" rx="16" fill="#0c8fce" fill-opacity="0.04" stroke="#0c8fce" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="54" y="296" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#0c8fce">externalTrafficPolicy: Local &#8212; the sender survives (commit it, or self-heal undoes the fix)</text>
<!-- client -->
<rect x="66" y="318" width="170" height="84" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="151" y="350" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">client</text>
<text x="151" y="372" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">src 10.0.11.42</text>
<!-- LB VIP backend-local -->
<rect x="306" y="318" width="200" height="84" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="406" y="348" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#7c3aed">LB VIP &#8212; backend node only</text>
<text x="406" y="370" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">no cross-node forward</text>
<text x="406" y="387" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">no SNAT needed</text>
<!-- policy sees real client -->
<rect x="576" y="318" width="220" height="84" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="686" y="348" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0c8fce">policy evaluates</text>
<text x="686" y="370" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">identity: 10.0.11.42</text>
<text x="686" y="387" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">the envelope kept its address</text>
<!-- precise rule -->
<rect x="866" y="318" width="200" height="84" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="966" y="348" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">a precise allow</text>
<text x="966" y="370" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">fromCIDR the LAN &#183; a /32 peer</text>
<text x="966" y="387" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">least privilege, possible again</text>
<line x1="236" y1="360" x2="302" y2="360" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="506" y1="360" x2="572" y2="360" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="796" y1="360" x2="862" y2="360" stroke="#0c8fce" stroke-width="2" marker-end="url(#arrc2)"></line>
<!-- footnote -->
<text x="60" y="446" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" fill="#64748b">Caveats that keep it honest: overlay/subnet routers still masquerade regardless &#8212; and swapping the LB plumbing itself (kube-proxy &#8594; eBPF) changes what arrives under an unchanged policy. Observe before you allow.</text>
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

+71
View File
@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="490" viewBox="0 0 1180 490" role="img" aria-label="A three-stage timeline for turning off anonymous registry pulls. Stage: one pull credential fanned out inert into six node config repos while the registry door stays propped open with anonymous read on. Prove: each node reboots onto the credential and is gated by a real pull of a mirror-only image through its own runtime - three of six passed, one parked for hardware repair. Flip: anonymous read goes off only at six of six. The per-node gate is the highlighted step, not the flip.">
<rect width="1180" height="490" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Stage &#8594; prove &#8594; flip: the split in time</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the flip is the trivial step &#8212; the per-node gate is where the safety lives</text>
<!-- timeline spine -->
<line x1="80" y1="105" x2="1100" y2="105" stroke="#64748b" stroke-width="1.5"></line>
<circle cx="230" cy="105" r="5" fill="#7c3aed"></circle>
<circle cx="590" cy="105" r="5" fill="#0c8fce"></circle>
<circle cx="950" cy="105" r="5" fill="#c026d3"></circle>
<!-- STAGE -->
<text x="230" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#7c3aed">STAGE</text>
<rect x="90" y="152" width="280" height="200" rx="14" fill="#7c3aed" fill-opacity="0.04" stroke="#7c3aed" stroke-width="2"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="110" y="182">one pull credential</text>
<text x="110" y="202">&#8594; six node repos, via git</text>
<text x="110" y="222">&#8594; sops placeholder, never</text>
<text x="122" y="240">the value</text>
</g>
<text x="110" y="272" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" font-weight="700" fill="#0e1726">door stays propped:</text>
<text x="110" y="292" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">anonymous read still ON</text>
<text x="110" y="330" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">inert &#183; reviewable &#183; free to revert</text>
<!-- PROVE (highlighted) -->
<text x="590" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0c8fce">PROVE &#8212; the gate</text>
<rect x="450" y="152" width="280" height="200" rx="14" fill="#0c8fce" fill-opacity="0.07" stroke="#0c8fce" stroke-width="3"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="470" y="182">per node: reboot onto auth,</text>
<text x="470" y="202">then a REAL pull of a</text>
<text x="470" y="222">mirror-only image through</text>
<text x="470" y="242">the node's own runtime</text>
</g>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="12">
<text x="470" y="280" fill="#0c8fce">&#10003; &#10003; &#10003; three gated</text>
<text x="470" y="302" fill="#64748b">&#9675; &#9675; awaiting their window</text>
<text x="470" y="324" fill="#c026d3">&#9888; one parked &#8212; hardware</text>
</g>
<text x="470" y="344" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11" fill="#64748b">no cache or fallback can fake a pass</text>
<!-- FLIP -->
<text x="950" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#c026d3">FLIP &#8212; only at 6/6</text>
<rect x="810" y="152" width="280" height="200" rx="14" fill="#ffffff" stroke="#c026d3" stroke-width="2" stroke-dasharray="7 6"></rect>
<g font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">
<text x="830" y="182">registry config:</text>
<text x="830" y="202">anonymous read &#8594; OFF</text>
</g>
<text x="830" y="240" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" font-weight="700" fill="#c026d3">blocked, deliberately,</text>
<text x="830" y="260" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" font-weight="700" fill="#c026d3">until the last key turns</text>
<text x="830" y="298" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">a staged lock costs nothing;</text>
<text x="830" y="316" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">a turned lock with one unproven</text>
<text x="830" y="334" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="11.5" fill="#64748b">key costs a boot loop</text>
<!-- arrows between stages -->
<g stroke="#64748b" stroke-width="2" fill="none">
<line x1="370" y1="252" x2="438" y2="252"></line>
<polygon points="438,246 450,252 438,258" fill="#64748b" stroke="none"></polygon>
<line x1="730" y1="252" x2="798" y2="252"></line>
<polygon points="798,246 810,252 798,258" fill="#64748b" stroke="none"></polygon>
</g>
<!-- failure-surface note -->
<rect x="90" y="384" width="1000" height="52" rx="12" fill="#0e1726" fill-opacity="0.03" stroke="#64748b" stroke-width="1.4" stroke-dasharray="5 5"></rect>
<text x="590" y="406" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#0e1726">why the split: this change fails only at the NEXT BOOT &#8212; and the failing node is the one that can't report it</text>
<text x="590" y="426" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#64748b">containerd 401-fallback: unreliable (issues #7321, #9997) &#8212; never boot-and-hope</text>
<!-- footer -->
<text x="60" y="472" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12" fill="#64748b">readiness is never inferred from the node coming back &#8212; readiness is the key turning in the door, observed, per node</text>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

+69
View File
@@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="440" viewBox="0 0 1180 440" role="img" aria-label="Two isolation models compared. Left: three containers as processes sharing one host kernel, separated only by dotted namespace lines - an escape lands on the host. Right: each pod boots inside its own microVM with its own guest kernel behind the hardware hypervisor - an escape lands inside a disposable virtual machine, not on the host.">
<rect width="1180" height="440" fill="#ffffff"></rect>
<!-- titles -->
<text x="60" y="52" font-family="ui-monospace,Menlo,Consolas,monospace" font-size="17" font-weight="600" fill="#0f172a">namespaces: one shared kernel</text>
<text x="640" y="52" font-family="ui-monospace,Menlo,Consolas,monospace" font-size="17" font-weight="600" fill="#0f172a">Kata: a kernel per pod</text>
<!-- ===== LEFT: shared kernel ===== -->
<!-- three container processes, dotted separations -->
<g font-family="ui-monospace,Menlo,Consolas,monospace" font-size="14" text-anchor="middle">
<rect x="60" y="84" width="130" height="86" rx="8" fill="none" stroke="#0c8fce" stroke-width="2" stroke-dasharray="6 5"/>
<text x="125" y="122" fill="#0f172a">container</text><text x="125" y="142" fill="#64748b">process</text>
<rect x="215" y="84" width="130" height="86" rx="8" fill="none" stroke="#0c8fce" stroke-width="2" stroke-dasharray="6 5"/>
<text x="280" y="122" fill="#0f172a">container</text><text x="280" y="142" fill="#64748b">process</text>
<rect x="370" y="84" width="130" height="86" rx="8" fill="none" stroke="#c026d3" stroke-width="2" stroke-dasharray="6 5"/>
<text x="435" y="122" fill="#0f172a">untrusted</text><text x="435" y="142" fill="#64748b">process</text>
<!-- escape arrow: through the dotted floor into the kernel -->
<path d="M435 172 L435 208" stroke="#c026d3" stroke-width="2.5" marker-end="url(#am)"/>
<text x="522" y="196" fill="#c026d3" font-size="13">escape = host</text>
<!-- one kernel slab -->
<rect x="60" y="214" width="440" height="64" rx="8" fill="#eef2f7" stroke="#334155" stroke-width="2"/>
<text x="280" y="242" fill="#0f172a" font-weight="600">ONE host kernel</text>
<text x="280" y="262" fill="#64748b">shared by every container and the node</text>
<!-- host hardware -->
<rect x="60" y="292" width="440" height="46" rx="8" fill="none" stroke="#94a3b8" stroke-width="2"/>
<text x="280" y="321" fill="#64748b">host hardware</text>
<text x="280" y="376" fill="#64748b" font-size="13">dotted lines are namespaces:</text>
<text x="280" y="396" fill="#64748b" font-size="13">visibility control, not a security boundary</text>
</g>
<!-- ===== RIGHT: kata microVMs ===== -->
<g font-family="ui-monospace,Menlo,Consolas,monospace" font-size="14" text-anchor="middle">
<!-- two microVMs, solid walls, each with its own guest kernel -->
<g>
<rect x="640" y="84" width="220" height="150" rx="10" fill="none" stroke="#334155" stroke-width="3"/>
<text x="750" y="112" fill="#0f172a">pod</text>
<rect x="662" y="126" width="176" height="42" rx="6" fill="none" stroke="#0c8fce" stroke-width="2"/>
<text x="750" y="152" fill="#0f172a">container process</text>
<rect x="662" y="180" width="176" height="38" rx="6" fill="#eef2f7" stroke="#7c3aed" stroke-width="2"/>
<text x="750" y="204" fill="#7c3aed">own guest kernel</text>
</g>
<g>
<rect x="890" y="84" width="220" height="150" rx="10" fill="none" stroke="#334155" stroke-width="3"/>
<text x="1000" y="112" fill="#0f172a">untrusted pod</text>
<rect x="912" y="126" width="176" height="42" rx="6" fill="none" stroke="#c026d3" stroke-width="2"/>
<text x="1000" y="152" fill="#0f172a">agent process</text>
<rect x="912" y="180" width="176" height="38" rx="6" fill="#eef2f7" stroke="#7c3aed" stroke-width="2"/>
<text x="1000" y="204" fill="#7c3aed">own guest kernel</text>
</g>
<!-- escape arrow: contained inside the VM wall -->
<path d="M1000 160 Q 1052 168 1046 186" fill="none" stroke="#c026d3" stroke-width="2.5" marker-end="url(#am)"/>
<text x="1010" y="256" fill="#c026d3" font-size="13">escape = still inside a throwaway VM</text>
<!-- hypervisor + hardware -->
<rect x="640" y="272" width="470" height="46" rx="8" fill="#eef2f7" stroke="#334155" stroke-width="2"/>
<text x="875" y="301" fill="#0f172a" font-weight="600">hypervisor (KVM) - CPU-enforced boundary</text>
<rect x="640" y="332" width="470" height="42" rx="8" fill="none" stroke="#94a3b8" stroke-width="2"/>
<text x="875" y="359" fill="#64748b">host hardware (VT-x)</text>
<text x="875" y="406" fill="#64748b" font-size="13">runtimeClassName: kata - same kubectl, different boundary</text>
</g>
<!-- divider -->
<line x1="573" y1="70" x2="573" y2="400" stroke="#e2e8f0" stroke-width="2"/>
<defs>
<marker id="am" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0 0L10 5L0 10z" fill="#c026d3"/>
</marker>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

+1 -1
View File
@@ -31,7 +31,7 @@ retry() {
}
echo ">> building ${REF}:${TAG}"
retry buildah build --retry 3 --retry-delay 5s --layers -t "${REF}:${TAG}" -t "${REF}:latest" .
retry buildah build --retry 3 --retry-delay 5s --layers --build-arg GIT_SHA="${TAG}" -t "${REF}:${TAG}" -t "${REF}:latest" .
if [[ "${1:-}" == "push" ]]; then
echo ">> pushing ${REF}:${TAG}"
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// Strip a corner watermark (the generator's four-point sparkle) from a cover/hero.
// Method per the astro-static-site skill: feathered disc mask at a HARD-CODED centre,
// seed with the border mean, then Jacobi-diffuse so the patch blends into the surround.
// Deliberately NOT brightness-auto-detected - that locks onto neon edges and highlights.
//
// node scripts/dewatermark.mjs <in> <out> [cx] [cy] [r] [iters]
//
// Default centre is the usual mark position on a 1600x1074 render.
import sharp from "sharp";
const [, , inPath, outPath, cxA, cyA, rA, itA] = process.argv;
if (!inPath || !outPath) {
console.error("usage: dewatermark.mjs <in> <out> [cx] [cy] [r] [iters]");
process.exit(1);
}
const R = Number(rA ?? 46);
const ITERS = Number(itA ?? 900);
const img = sharp(inPath);
const { width: W, height: H } = await img.metadata();
const CX = Number(cxA ?? 1451);
const CY = Number(cyA ?? 922);
const { data, info } = await img.raw().toBuffer({ resolveWithObject: true });
const CH = info.channels;
// feathered disc: 1 inside, 0 outside, smooth over the last 6px so the seam disappears
const mask = new Float32Array(W * H);
const x0 = Math.max(0, CX - R - 4), x1 = Math.min(W - 1, CX + R + 4);
const y0 = Math.max(0, CY - R - 4), y1 = Math.min(H - 1, CY + R + 4);
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const d = Math.hypot(x - CX, y - CY);
mask[y * W + x] = d <= R - 6 ? 1 : d >= R ? 0 : (R - d) / 6;
}
}
// seed every masked pixel with the mean colour of the ring just outside the mask
const ring = [];
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const d = Math.hypot(x - CX, y - CY);
if (d > R && d <= R + 4) ring.push((y * W + x) * CH);
}
}
const seed = [0, 0, 0];
for (const o of ring) for (let c = 0; c < 3; c++) seed[c] += data[o + c];
for (let c = 0; c < 3; c++) seed[c] = ring.length ? seed[c] / ring.length : 0;
const work = new Float32Array(W * H * 3);
for (let i = 0; i < W * H; i++) {
for (let c = 0; c < 3; c++) {
work[i * 3 + c] = mask[i] > 0 ? seed[c] : data[i * CH + c];
}
}
// Jacobi diffusion: each masked pixel relaxes toward its 4-neighbour average,
// known pixels stay pinned, so the fill inherits the surrounding gradient.
const next = new Float32Array(work);
for (let it = 0; it < ITERS; it++) {
for (let y = Math.max(1, y0); y <= Math.min(H - 2, y1); y++) {
for (let x = Math.max(1, x0); x <= Math.min(W - 2, x1); x++) {
const i = y * W + x;
if (mask[i] <= 0) continue;
for (let c = 0; c < 3; c++) {
next[i * 3 + c] =
(work[(i - 1) * 3 + c] + work[(i + 1) * 3 + c] +
work[(i - W) * 3 + c] + work[(i + W) * 3 + c]) / 4;
}
}
}
work.set(next);
}
// composite back through the feather so the edge of the patch is invisible
const out = Buffer.from(data);
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const i = y * W + x, m = mask[i];
if (m <= 0) continue;
for (let c = 0; c < 3; c++) {
const o = i * CH + c;
out[o] = Math.round(data[o] * (1 - m) + work[i * 3 + c] * m);
}
}
}
await sharp(out, { raw: { width: W, height: H, channels: CH } })
.webp({ quality: 85 })
.toFile(outPath);
console.log(`dewatermarked ${inPath} -> ${outPath} (centre ${CX},${CY} r=${R}, ${ITERS} iters)`);
+3 -3
View File
@@ -23,7 +23,7 @@ const olderCount = experience.length - recent.length;
</div>
</dl>
<h3 class="about__sub mono"># education</h3>
<h3 class="about__sub mono"><span class="about__hash">#</span> education</h3>
<ul class="about__education">
{education.map((e) => <li>{e}</li>)}
</ul>
@@ -59,7 +59,7 @@ const olderCount = experience.length - recent.length;
</ol>
{olderCount > 0 && (
<p class="about__more mono">
+ {olderCount} earlier roles (ELGAS, Darktime){cvAvailable ? " see the CV" : ""}.
+ {olderCount} earlier roles (ELGAS, Darktime){cvAvailable ? " - see the CV" : ""}.
</p>
)}
</div>
@@ -100,7 +100,7 @@ const olderCount = experience.length - recent.length;
font-size: var(--step--1);
letter-spacing: 0.02em;
}
.about__sub::first-letter {
.about__hash {
color: var(--accent);
}
.about__education {
+1 -1
View File
@@ -1,5 +1,5 @@
---
// Circuit divider gradient hairlines meeting two rotated-square "diamonds"
// Circuit divider - gradient hairlines meeting two rotated-square "diamonds"
// (cyan, magenta). Pure CSS, from the design handoff.
---
+39 -16
View File
@@ -1,7 +1,7 @@
---
// The fleet constellation 6 real nodes as IC chips on PCB traces.
// The fleet constellation - 6 real nodes as IC chips on PCB traces.
// Pure SVG + scoped CSS keyframes (zero JS; reduced-motion turns every animation off).
// Anatomy per the design handoff: each route is TWO parallel traces (offset ±(2,-3))
// Anatomy per the design handoff: each route is TWO parallel traces (offset ±(2,-3)) -
// one carries a cyan packet outbound, the other a magenta packet inbound. Packets are
// stroke-dasharray 9/127 sliding via stroke-dashoffset. Hub = cave (56px chip, radar ring).
---
@@ -42,7 +42,7 @@
<path class="t-base t-mag" d="M380 310 H470 L540 380 H602 L640 420"></path>
<path class="t-pkt t-pkt--mag pkt-rev" d="M380 310 H470 L540 380 H602 L640 420"></path>
</g>
<!-- kate (standby dimmer) -->
<!-- kate (standby - dimmer) -->
<g transform="translate(2,-3)" class="standby">
<path class="t-glow t-cyan" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
<path class="t-base t-cyan" d="M380 310 H300 L240 250 V202 L188 150 H176"></path>
@@ -109,13 +109,12 @@
<circle class="core core--cyan pulse" cx="380" cy="310" r="5"></circle>
<rect class="plate plate--hub" x="338" y="352" width="84" height="24" rx="4"></rect>
<text class="lbl lbl--hub" x="380" y="368" text-anchor="middle">cave</text>
<text class="cap cap--hub" x="380" y="392" text-anchor="middle">139 apps · reconciling</text>
<!-- ── awake: alfred ── -->
<circle cx="612" cy="128" r="24" fill="rgba(63,186,245,0.07)"></circle>
<rect class="chip chip--cyan" x="595" y="111" width="34" height="34" rx="5"></rect>
<circle class="core core--cyan pulse" cx="612" cy="128" r="4"></circle>
<rect class="plate plate--cyan" x="576" y="152" width="72" height="20" rx="3"></rect>
<!-- ── awake: alfred (Intel compute - NUC iGPU/QuickSync) ── -->
<circle cx="612" cy="128" r="24" fill="rgba(0,199,253,0.07)"></circle>
<rect class="chip chip--intel" x="595" y="111" width="34" height="34" rx="5"></rect>
<circle class="core core--intel pulse" cx="612" cy="128" r="4"></circle>
<rect class="plate plate--intel" x="576" y="152" width="72" height="20" rx="3"></rect>
<text class="lbl" x="612" y="166" text-anchor="middle">alfred</text>
<!-- ── awake: robin ── -->
@@ -130,7 +129,6 @@
<circle class="core core--cyan pulse-slow" cx="160" cy="150" r="3.5"></circle>
<rect class="plate plate--dim" x="126" y="172" width="68" height="20" rx="3"></rect>
<text class="lbl lbl--dim" x="160" y="186" text-anchor="middle">kate</text>
<text class="cap cap--dim" x="160" y="206" text-anchor="middle">standby · 33 apps known</text>
<!-- ── standby: wgirl ── -->
<rect class="chip chip--mag" x="199" y="471" width="30" height="30" rx="5"></rect>
@@ -138,11 +136,21 @@
<rect class="plate plate--dim" x="180" y="508" width="68" height="20" rx="3"></rect>
<text class="lbl lbl--dim" x="214" y="522" text-anchor="middle">wgirl</text>
<!-- ── standby: elfastc ── -->
<rect class="chip chip--cyan" x="437" y="533" width="30" height="30" rx="5"></rect>
<circle class="core core--cyan pulse-slow" cx="452" cy="548" r="3.5"></circle>
<!-- ── standby: elfastc (Intel compute - 2× Arc B580) ── -->
<rect class="chip chip--intel" x="437" y="533" width="30" height="30" rx="5"></rect>
<circle class="core core--intel pulse-slow" cx="452" cy="548" r="3.5"></circle>
<rect class="plate plate--dim" x="412" y="570" width="80" height="20" rx="3"></rect>
<text class="lbl lbl--dim" x="452" y="584" text-anchor="middle">elfastc</text>
<!-- ── key: compute colours (right side - left edge clips on narrow heroes) ── -->
<g aria-label="compute colour key">
<rect class="core--mag" x="620" y="572" width="8" height="8" rx="2"></rect>
<text class="cap cap--key" x="634" y="580">nvidia compute</text>
<rect class="core--intel" x="620" y="592" width="8" height="8" rx="2"></rect>
<text class="cap cap--key" x="634" y="600">intel compute</text>
<rect class="key--bat" x="620" y="612" width="8" height="8" rx="2"></rect>
<text class="cap cap--key" x="634" y="620">core / services</text>
</g>
</svg>
<style>
@@ -193,8 +201,10 @@
.chip--hub { stroke-width: 2; filter: drop-shadow(0 0 10px rgba(63, 186, 245, 0.45)); }
.chip--cyan { stroke: var(--accent); stroke-width: 1.5; filter: drop-shadow(0 0 8px rgba(63, 186, 245, 0.55)); }
.chip--mag { stroke: var(--accent-2); stroke-width: 1.5; filter: drop-shadow(0 0 8px rgba(232, 121, 249, 0.55)); }
.chip--intel { stroke: var(--accent-intel); stroke-width: 1.5; filter: drop-shadow(0 0 8px rgba(0, 199, 253, 0.55)); }
.core--cyan { fill: var(--accent); filter: drop-shadow(0 0 6px rgba(63, 186, 245, 1)); }
.core--mag { fill: var(--accent-2); filter: drop-shadow(0 0 5px rgba(232, 121, 249, 1)); }
.core--intel { fill: var(--accent-intel); filter: drop-shadow(0 0 6px rgba(0, 199, 253, 1)); }
.pulse { animation: bzPulse 2.4s ease-in-out infinite; }
.pulse-slow { animation: bzPulse 3.4s ease-in-out infinite; }
.pins-cyan line { stroke: rgba(63, 186, 245, 0.6); stroke-width: 1.5; }
@@ -203,6 +213,7 @@
.plate--hub { stroke: rgba(63, 186, 245, 0.4); }
.plate--cyan { stroke: rgba(63, 186, 245, 0.35); }
.plate--mag { stroke: rgba(232, 121, 249, 0.35); }
.plate--intel { stroke: rgba(0, 199, 253, 0.35); }
.plate--dim { stroke: rgba(126, 139, 161, 0.3); }
.lbl {
fill: #e8f2fb;
@@ -218,6 +229,14 @@
filter: drop-shadow(0 0 4px rgba(63, 186, 245, 0.7));
}
.cap--dim { fill: #5c6a82; font-size: 9px; }
.cap--key { fill: #7e8ba1; font-size: 9px; }
/* gloss bat-black with a faint gold ring - the batcave swatch for core/services */
.key--bat {
fill: #10131c;
stroke: rgba(245, 197, 24, 0.55);
stroke-width: 1;
filter: drop-shadow(0 0 4px rgba(245, 197, 24, 0.35));
}
/* radar ring off the hub */
.ring {
@@ -226,11 +245,15 @@
animation: bzRing 3.6s ease-out infinite;
}
@keyframes bzDash { to { stroke-dashoffset: -260; } }
@keyframes bzDashRev { to { stroke-dashoffset: 260; } }
/* dash pattern is 9+127 = a 136px period - the loop offset MUST be an exact multiple of it
(2×136 = 272) or every packet dot visibly teleports at the loop seam. */
@keyframes bzDash { to { stroke-dashoffset: -272; } }
@keyframes bzDashRev { to { stroke-dashoffset: 272; } }
@keyframes bzPulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@keyframes bzRing {
0% { transform: scale(0.5); opacity: 0.9; }
/* start AND end at opacity 0 so the 3.6s restart never pops */
0% { transform: scale(0.5); opacity: 0; }
10% { opacity: 0.9; }
100% { transform: scale(1.7); opacity: 0; }
}
+7 -1
View File
@@ -1,4 +1,5 @@
---
import { cvAvailable } from "../lib/assets";
import { site } from "../data/site";
import { socials } from "../data/socials";
---
@@ -7,12 +8,17 @@ import { socials } from "../data/socials";
<div class="contact__body">
<p class="contact__lead">
Open to conversations about platform engineering, edge infrastructure, and
GPU/AI systems. Based in {site.location} for a low-ms reply, ping me on
GPU/AI systems. Based in {site.location} - for a low-ms reply, ping me on
LinkedIn or email; everything else is best-effort delivery.
</p>
<a class="btn btn--primary contact__mail mono" href={`mailto:${site.email}`}>
{site.email}
</a>
{cvAvailable && (
<a class="btn contact__cv" href="/cv.pdf" download>
Download CV (PDF)
</a>
)}
</div>
<ul class="contact__links">
+3 -3
View File
@@ -2,7 +2,7 @@
// Architecture diagrams as static SVG (authored to match the site style), shown on a
// constant light card so the fixed-colour art stays legible in BOTH light and dark themes.
// CSP-clean: a plain same-origin <img> (img-src 'self'); no inline script, no web fonts.
// SVG sources live in /public/diagrams/<name>.svg the same files the blog posts embed.
// SVG sources live in /public/diagrams/<name>.svg - the same files the blog posts embed.
interface Props {
name: string;
caption?: string;
@@ -10,11 +10,11 @@ interface Props {
const { name, caption } = Astro.props;
const captions: Record<string, string> = {
"edge-ai": "Design → single-press pipeline → readiness-gated GPU inference at the edge",
"edge-ai": "Design → single-touch pipeline → readiness-gated GPU inference at the edge",
"iac-fleet": "One source of truth → AWX/Ansible → identical edge nodes, even air-gapped",
homelab: "Bare metal → GitOps clusters → services, exposed outbound-only via a tunnel",
"authentik-sso": "One identity provider, 2FA at the flow; apps validate over an internal back channel",
"outbound-exposure": "No open ports the origin dials out; the edge is the perimeter",
"outbound-exposure": "No open ports - the origin dials out; the edge is the perimeter",
};
const cap = caption ?? captions[name];
---
+54 -2
View File
@@ -25,7 +25,7 @@ const { kind = "generic" } = Astro.props;
</>
) : kind === "homelab" ? (
<>
{/* hub and spokes the fleet */}
{/* hub and spokes - the fleet */}
<line x1="200" y1="90" x2="90" y2="45" class="ln ln--c" />
<line x1="200" y1="90" x2="90" y2="135" class="ln ln--c" />
<line x1="200" y1="90" x2="310" y2="45" class="ln ln--m" />
@@ -37,9 +37,61 @@ const { kind = "generic" } = Astro.props;
<circle cx="310" cy="45" r="12" class="nd nd--m" />
<circle cx="310" cy="135" r="12" class="nd nd--m" />
</>
) : kind === "gpu" ? (
<>
{/* GPU die with passthrough lanes feeding two edge nodes */}
<rect x="150" y="55" width="100" height="70" rx="6" class="nd nd--c" />
<line x1="164" y1="55" x2="164" y2="40" class="ln ln--c" />
<line x1="186" y1="55" x2="186" y2="40" class="ln ln--c" />
<line x1="208" y1="55" x2="208" y2="40" class="ln ln--m" />
<line x1="230" y1="55" x2="230" y2="40" class="ln ln--m" />
<rect x="172" y="72" width="56" height="36" rx="3" fill="none" stroke="rgba(63,186,245,0.4)" stroke-width="1" />
<text x="200" y="94" text-anchor="middle" class="tx">GPU</text>
<line x1="250" y1="70" x2="330" y2="55" class="ln ln--c" />
<line x1="250" y1="110" x2="330" y2="125" class="ln ln--m" />
<rect x="330" y="40" width="30" height="26" rx="4" class="nd nd--dim" />
<rect x="330" y="112" width="30" height="26" rx="4" class="nd nd--m" />
<circle cx="290" cy="62" r="2.5" class="dot dot--c pulse" />
<circle cx="290" cy="117" r="2.5" class="dot dot--m pulse-slow" />
</>
) : kind === "globe" ? (
<>
{/* globe of regions - a modernised multi-region estate */}
<circle cx="200" cy="90" r="52" fill="none" stroke="rgba(63,186,245,0.35)" stroke-width="1" />
<ellipse cx="200" cy="90" rx="52" ry="20" fill="none" stroke="rgba(63,186,245,0.25)" stroke-width="1" />
<ellipse cx="200" cy="90" rx="20" ry="52" fill="none" stroke="rgba(63,186,245,0.25)" stroke-width="1" />
<line x1="148" y1="90" x2="252" y2="90" stroke="rgba(63,186,245,0.25)" stroke-width="1" />
<circle cx="176" cy="62" r="4" class="nd nd--c" />
<circle cx="228" cy="66" r="4" class="nd nd--m" />
<circle cx="182" cy="118" r="4" class="nd nd--dim" />
<circle cx="222" cy="114" r="4" class="nd nd--c" />
<circle cx="200" cy="90" r="3" class="dot dot--c pulse" />
<line x1="176" y1="62" x2="200" y2="90" class="ln ln--c" />
<line x1="228" y1="66" x2="200" y2="90" class="ln ln--m" />
<line x1="182" y1="118" x2="200" y2="90" class="ln ln--c" />
<line x1="222" y1="114" x2="200" y2="90" class="ln ln--m" />
</>
) : kind === "network-fleet" ? (
<>
{/* fleet of switches, single-pane managed */}
<rect x="170" y="30" width="60" height="24" rx="4" class="nd nd--c" />
<text x="200" y="47" text-anchor="middle" class="tx">NCM</text>
<line x1="200" y1="54" x2="200" y2="70" class="ln ln--c" />
<line x1="80" y1="70" x2="320" y2="70" class="ln ln--c" />
<line x1="100" y1="70" x2="100" y2="120" class="ln ln--dim" />
<line x1="160" y1="70" x2="160" y2="120" class="ln ln--dim" />
<line x1="240" y1="70" x2="240" y2="120" class="ln ln--m" />
<line x1="300" y1="70" x2="300" y2="120" class="ln ln--m" />
<rect x="82" y="120" width="36" height="18" rx="3" class="nd nd--dim" />
<rect x="142" y="120" width="36" height="18" rx="3" class="nd nd--dim" />
<rect x="222" y="120" width="36" height="18" rx="3" class="nd nd--m" />
<rect x="282" y="120" width="36" height="18" rx="3" class="nd nd--m" />
<circle cx="100" cy="70" r="2.5" class="dot dot--c pulse" />
<circle cx="300" cy="70" r="2.5" class="dot dot--m pulse-slow" />
</>
) : (
<>
{/* pipeline commits flowing through gates to a fleet */}
{/* pipeline - commits flowing through gates to a fleet */}
<line x1="40" y1="90" x2="360" y2="90" class="ln ln--c" />
<rect x="88" y="70" width="40" height="40" rx="5" class="nd nd--c" />
<rect x="180" y="70" width="40" height="40" rx="5" class="nd nd--dim" />
+195 -92
View File
@@ -7,51 +7,51 @@ const linkedinHref = socials.find((s) => s.label === "LinkedIn")?.href;
const emailHref = socials.find((s) => s.label === "Email")?.href;
---
<!-- One terminal session = the whole footer. Every value baked at build time, zero runtime calls. -->
<footer class="footer">
<!-- the GitOps receipt — every value baked at build time, zero runtime calls -->
<div class="container">
<p class="footer__receipt mono" data-reveal>
<span class="footer__receipt-label">RECEIPT</span>
<span class="footer__rdot">·</span> HEAD <span class="footer__rval">{buildInfo.sha}</span>
<span class="footer__rdot">·</span> built <span class="footer__rval">{buildInfo.builtAt}</span>
<span class="footer__rdot">·</span> reconciled by <span class="footer__rval">ArgoCD</span>
<span class="footer__rdot">·</span> <span class="footer__rok">✓ Synced · Healthy</span>
</p>
<div class="term mono" data-reveal>
<div class="term__bar">
<span class="term__chrome" aria-hidden="true">
<span class="term__led"></span><span class="term__led"></span><span class="term__led term__led--lit"></span>
</span>
<span class="term__title">jonny@bztmon:~ · session receipt</span>
<span class="term__health"><span class="term__ok">✓</span> Synced · Healthy</span>
</div>
<div class="container footer__inner">
<div>
<p class="mono footer__name">{site.name}</p>
<p class="footer__meta">
{site.role} · Served from a homelab Kubernetes cluster over an encrypted tunnel.
<div class="term__body">
<p class="term__line"><span class="term__prompt">$</span> whoami</p>
<p class="term__out">
<span class="term__name">{site.name}</span>
<span class="term__sep"> - </span> {site.role}
</p>
<p class="footer__contact">
For a low-ms reply, ping me on{" "}
<p class="term__line"><span class="term__prompt">$</span> traceroute www.bztmon.com</p>
<p class="term__out">
served from a homelab Kubernetes cluster, over an encrypted tunnel
</p>
<p class="term__line"><span class="term__prompt">$</span> git log -1 --format=receipt</p>
<p class="term__out">
HEAD <span class="term__val">{buildInfo.sha}</span>
<span class="term__sep">·</span> built <span class="term__val">{buildInfo.builtAt}</span>
<span class="term__sep">·</span> reconciled by <span class="term__val">Argo CD</span>
</p>
<p class="term__line"><span class="term__prompt">$</span> contact</p>
<p class="term__out">
<a href={linkedinHref} rel="noopener noreferrer" target="_blank">LinkedIn</a>{" "}
or <a href={emailHref}>email</a> — everything else is best-effort delivery.
or <a href={emailHref}>email</a>
</p>
<p class="term__line" aria-hidden="true">
<span class="term__prompt">$</span> <span class="term__cursor"></span>
</p>
</div>
<div class="footer__links">
{
socials.map((s) => (
<a
href={s.href}
rel={s.external ? "noopener noreferrer" : undefined}
aria-label={s.label}
title={s.label}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d={s.icon} fill="currentColor" />
</svg>
</a>
))
}
</div>
</div>
<div class="container footer__base">
<span class="mono">© {year} {site.name}</span>
<span class="mono footer__dot">·</span>
<span class="mono">
Content licensed under{" "}
<div class="term__statusbar">
<span>© {year} {site.name}</span>
<span class="term__sdot" aria-hidden="true">·</span>
<a
href={site.license.url}
rel="license noopener noreferrer"
@@ -60,103 +60,206 @@ const emailHref = socials.find((s) => s.label === "Email")?.href;
>
{site.license.name}
</a>
<span class="term__sdot" aria-hidden="true">·</span>
<span>built with Astro, shipped via GitOps</span>
<span class="term__spring" aria-hidden="true"></span>
<span class="term__socials">
{
socials.map((s) => (
<a
href={s.href}
rel={s.external ? "noopener noreferrer" : undefined}
aria-label={s.label}
title={s.label}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d={s.icon} fill="currentColor" />
</svg>
</a>
))
}
</span>
<span class="mono footer__dot">·</span>
<span class="mono">built with Astro, shipped via GitOps</span>
</div>
</div>
</div>
</footer>
<style>
.footer {
border-top: 1px solid var(--border);
padding-block: var(--space-7) var(--space-6);
margin-top: var(--space-8);
color: var(--text-dim);
}
.footer__receipt {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint);
/* ── the terminal panel ─────────────────────────────────────────────── */
.term {
position: relative;
border: 1px solid var(--border-accent);
border-radius: var(--radius-btn);
border-radius: var(--radius);
background: var(--panel);
backdrop-filter: var(--panel-blur);
-webkit-backdrop-filter: var(--panel-blur);
padding: 10px 14px;
margin-bottom: var(--space-6);
width: fit-content;
max-width: 100%;
overflow: hidden;
font-size: 12.5px;
letter-spacing: 0.03em;
box-shadow: 0 0 24px -14px var(--accent-glow);
}
.footer__receipt-label {
color: var(--accent);
letter-spacing: 0.14em;
/* faint CRT scanlines across the whole session (static - not motion) */
.term::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
transparent 0 2px,
color-mix(in srgb, var(--text) 3%, transparent) 2px 3px
);
}
.footer__rdot {
opacity: 0.5;
/* ── title bar ──────────────────────────────────────────────────────── */
.term__bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
color: var(--text-faint);
font-size: 11px;
}
.footer__rval {
.term__chrome {
display: inline-flex;
gap: 5px;
}
.term__led {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--border-strong);
}
.term__led--lit {
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
}
.term__title {
letter-spacing: 0.08em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.term__health {
margin-left: auto;
white-space: nowrap;
color: var(--text-dim);
}
.footer__rok {
.term__ok {
color: var(--accent);
}
.footer__inner {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
justify-content: space-between;
align-items: center;
/* ── session body ───────────────────────────────────────────────────── */
.term__body {
padding: 14px 16px 10px;
display: grid;
gap: 2px;
}
.footer__name {
.term__line {
color: var(--text-dim);
}
.term__line + .term__out {
margin-bottom: 0.55rem;
}
.term__prompt {
color: var(--accent);
margin-right: 0.45rem;
}
.term__out {
color: var(--text-faint);
padding-left: 1.05rem;
max-width: 72ch;
}
.term__name {
color: var(--text);
font-weight: 600;
}
.footer__meta {
font-size: var(--step--1);
color: var(--text-faint);
margin-top: 0.25rem;
.term__sep {
opacity: 0.5;
margin-inline: 0.15rem;
}
.footer__contact {
font-size: var(--step--1);
color: var(--text-faint);
margin-top: 0.4rem;
.term__val {
color: var(--accent-2);
}
.footer__contact a {
.term__out a {
color: var(--text-dim);
text-decoration: underline;
text-underline-offset: 2px;
}
.footer__contact a:hover {
.term__out a:hover {
color: var(--accent);
}
.footer__links {
display: flex;
gap: var(--space-4);
/* blinking block cursor on the empty prompt */
.term__cursor {
display: inline-block;
width: 0.62em;
height: 1.05em;
vertical-align: text-bottom;
background: var(--accent);
animation: term-blink 1.1s steps(2, start) infinite;
}
.footer__links a {
color: var(--text-dim);
@keyframes term-blink {
to {
visibility: hidden;
}
.footer__links a:hover {
color: var(--accent);
}
.footer__base {
margin-top: var(--space-5);
font-size: 0.72rem;
color: var(--text-faint);
@media (prefers-reduced-motion: reduce) {
.term__cursor {
animation: none;
}
}
/* ── status bar (tmux-style) ────────────────────────────────────────── */
.term__statusbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 8px 14px;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 60%, transparent);
color: var(--text-faint);
font-size: 0.72rem;
}
.footer__base a {
.term__sdot {
opacity: 0.5;
}
.term__statusbar a {
color: var(--text-dim);
text-decoration: underline;
text-underline-offset: 2px;
}
.footer__base a:hover {
.term__statusbar a:hover {
color: var(--accent);
}
.term__spring {
flex: 1;
}
.term__socials {
display: inline-flex;
gap: var(--space-3);
}
.term__socials a {
color: var(--text-dim);
text-decoration: none;
}
.term__socials a:hover {
color: var(--accent);
}
@media (max-width: 560px) {
.term__health {
display: none;
}
.term__spring {
flex-basis: 100%;
}
}
</style>
+16 -5
View File
@@ -66,7 +66,7 @@ import Constellation from "./Constellation.astro";
<div class="hero__scroll mono" aria-hidden="true">
<span class="hero__diamond"></span>
<span>SCROLL DESCENDING: EDGE / CLUSTER / WORKLOAD / SECURITY</span>
<span>SCROLL - DESCENDING: EDGE / CLUSTER / WORKLOAD / SECURITY</span>
</div>
</section>
@@ -168,7 +168,7 @@ import Constellation from "./Constellation.astro";
line-height: 1.05;
letter-spacing: -0.005em;
padding-bottom: 0.14em;
/* two-line nameplate (mock parity) wrap at the space, keep the column clear
/* two-line nameplate (mock parity) - wrap at the space, keep the column clear
of the constellation's left-most node labels */
max-width: min-content;
background:
@@ -219,7 +219,7 @@ import Constellation from "./Constellation.astro";
gap: var(--space-3);
}
/* the typing bar glass panel */
/* the typing bar - glass panel */
.hero__fact {
margin-top: var(--space-6);
display: flex;
@@ -232,7 +232,7 @@ import Constellation from "./Constellation.astro";
backdrop-filter: var(--panel-blur);
-webkit-backdrop-filter: var(--panel-blur);
/* one-liner: the box sizes to its fact (smaller type) and may run a little
past the copy column there's clear board under it until the viz */
past the copy column - there's clear board under it until the viz */
width: max-content;
max-width: min(52rem, calc(100vw - 2 * var(--space-5)));
min-height: 20px;
@@ -303,7 +303,18 @@ import Constellation from "./Constellation.astro";
}
@media (max-width: 560px) {
.hero__viz { display: none; }
.hero__fact { font-size: 12px; }
.hero__scroll { display: none; }
/* phones: the fact bar fills the column and WRAPS like a real terminal -
the desktop one-liner (nowrap + max-content) busts a 390px viewport */
.hero__fact {
font-size: 12px;
width: 100%;
max-width: 100%;
}
.hero__fact-line {
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.5;
}
}
</style>
+49 -26
View File
@@ -2,29 +2,32 @@
import ThemeToggle from "./ThemeToggle.astro";
import { site } from "../data/site";
// `id` ties a nav link to a homepage section so the Motion layer animates its
// underline as that section scrolls through (scroll-spy). Projects/Blog navigate
// to their own pages on click, but on the homepage they track the #projects and
// #writing sections so every nav item gets the underline — in both directions.
// Plain links. Hover shows the underline; there is no scroll-spy - tracking the
// active section as you scrolled read as clunky, so the underline is hover-only.
const links = [
{ label: "About", href: "/#about", id: "about" },
{ label: "Skills", href: "/#skills", id: "skills" },
{ label: "Projects", href: "/projects/", id: "projects" },
{ label: "Blog", href: "/blog/", id: "writing" },
{ label: "Contact", href: "/#contact", id: "contact" },
{ label: "About", href: "/#about" },
{ label: "Skills", href: "/#skills" },
{ label: "Projects", href: "/projects/" },
{ label: "Blog", href: "/blog/" },
{ label: "Contact", href: "/#contact" },
{ label: "Learn", href: "https://learn.bztmon.com", external: true },
];
---
<header class="nav">
<div class="container nav__inner">
<a class="nav__brand mono" href="/" aria-label={`${site.name} home`}>
<a class="nav__brand mono" href="/" aria-label={`${site.name} - home`}>
<span class="nav__prompt">~/</span><span>{site.handle}</span><span class="nav__caret" aria-hidden="true">▮</span>
</a>
<nav class="nav__links" aria-label="Primary">
{links.map((l) => (
<a href={l.href} data-nav-id={l.id}>
{l.label}<span class="nav__ul" aria-hidden="true"></span>
<a
href={l.href}
target={l.external ? "_blank" : undefined}
rel={l.external ? "noopener noreferrer" : undefined}
>
{l.label}
</a>
))}
</nav>
@@ -95,20 +98,12 @@ const links = [
color: var(--text);
text-decoration: none;
}
/* Active-section / hover underline — scaleX scrubbed by the Motion layer
(spring). transform-origin left so it grows from the start of the label. */
.nav__ul {
position: absolute;
left: 0;
right: 0;
bottom: -0.45rem;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
box-shadow: 0 0 8px var(--accent-glow);
transform: scaleX(0);
transform-origin: 0 50%;
pointer-events: none;
/* Hover = glow, not an underline (owner call 2026-08-18: the bar read as clunky).
Keyboard focus gets the same treatment so the affordance is not mouse-only. */
.nav__links a:hover,
.nav__links a:focus-visible {
color: var(--accent);
text-shadow: 0 0 10px var(--accent-glow), 0 0 22px var(--accent-glow);
}
.nav__actions {
display: flex;
@@ -120,4 +115,32 @@ const links = [
display: flex;
}
}
/* phones: the tabs live on a compact second row (zero-JS, no hamburger) -
the bar wraps: brand + toggle on row 1, links across row 2 */
@media (max-width: 719px) {
.nav__inner {
height: auto;
flex-wrap: wrap;
row-gap: 0;
padding-block: 0.55rem 0.6rem;
}
.nav__links {
display: flex;
order: 3;
width: 100%;
gap: var(--space-4);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
font-size: 0.78rem;
padding-top: 0.5rem;
}
.nav__links::-webkit-scrollbar {
display: none;
}
.nav__links a {
flex-shrink: 0;
padding-bottom: 0.15rem;
}
}
</style>
+1 -1
View File
@@ -17,7 +17,7 @@ const { posts } = Astro.props;
<a class="post__link" href={`/blog/${post.id}/`}>
{post.data.hero && (
<div class="post__thumb">
<img src={`${post.data.hero}?v=2`} alt="" width="1600" height="1073" loading="lazy" decoding="async" />
<img src={`${post.data.hero}?v=3`} alt="" width="1600" height="1073" loading="lazy" decoding="async" />
</div>
)}
<div class="post__card-body">
+57 -3
View File
@@ -1,5 +1,5 @@
---
// Dossier card thumbnail diagram, DOSSIER/00N corner tag, title, "the argument",
// Dossier card - thumbnail diagram, DOSSIER/00N corner tag, title, "the argument",
// tag chips, mono meta footer. Facelift shape from the design handoff.
import type { CollectionEntry } from "astro:content";
import DossierThumb from "./DossierThumb.astro";
@@ -17,14 +17,24 @@ const dossierNo = String(index).padStart(3, "0");
<a class="dossier" href={href} data-reveal>
<div class="dossier__thumb">
<DossierThumb kind={p.diagram} />
{p.cover
? <img
class="dossier__cover"
src={`/covers/thumb/${p.cover}.webp?v=3`}
alt=""
width="640"
height="429"
loading="lazy"
decoding="async"
/>
: <DossierThumb kind={p.diagram} />}
<span class="dossier__tag mono">DOSSIER / {dossierNo}</span>
</div>
<div class="dossier__body">
<h3 class="dossier__title">
{p.title} <span class="dossier__arrow" aria-hidden="true">→</span>
</h3>
<p class="dossier__argument">The argument: {p.outcome}</p>
<p class="dossier__argument"><span class="dossier__outcome-label">Outcome</span> {p.outcome}</p>
<ul class="dossier__chips mono" role="list">
{p.stack.map((s) => <li class="dchip">{s}</li>)}
</ul>
@@ -63,8 +73,30 @@ const dossierNo = String(index).padStart(3, "0");
height: 180px;
background: #0b101c;
position: relative;
overflow: hidden;
border-bottom: 1px solid rgba(63, 186, 245, 0.12);
}
/* Cover art fills the thumb; the corner tag sits over it, so a scrim keeps
the tag legible against the brighter parts of the art. */
.dossier__cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 320ms var(--ease);
}
.dossier:hover .dossier__cover {
transform: scale(1.04);
}
.dossier__thumb:has(.dossier__cover)::after {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 60%;
height: 46px;
background: linear-gradient(160deg, rgba(9, 12, 20, 0.85), transparent 78%);
pointer-events: none;
}
:global([data-theme="light"]) .dossier__thumb {
background: var(--surface-2);
border-bottom-color: var(--border-accent);
@@ -79,12 +111,26 @@ const dossierNo = String(index).padStart(3, "0");
}
.dossier__body {
position: relative;
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
/* a faint on-theme grid fills the idle space above the chips/meta (the
margin-top:auto gap on shorter "argument" text) instead of a flat void */
.dossier__body::before {
content: "";
position: absolute;
inset: 68px 24px 64px;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 22px 22px;
mask-image: linear-gradient(180deg, black, transparent 85%);
pointer-events: none;
}
.dossier__title {
margin: 0;
font-family: var(--font-display);
@@ -100,6 +146,14 @@ const dossierNo = String(index).padStart(3, "0");
.dossier:hover .dossier__arrow {
transform: translateX(4px);
}
.dossier__outcome-label {
font-family: var(--mono, ui-monospace, monospace);
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--accent, #3fbaf5);
margin-right: 6px;
}
.dossier__argument {
margin: 0;
font-size: 14px;
+1 -1
View File
@@ -25,7 +25,7 @@ const { id, eyebrow, title, index } = Astro.props;
.section__head {
margin-bottom: var(--space-6);
}
/* A short cyan→magenta tick leads each section eyebrow board palette. */
/* A short cyan→magenta tick leads each section eyebrow - board palette. */
.section__head .eyebrow {
display: inline-flex;
align-items: center;
+49 -7
View File
@@ -1,5 +1,5 @@
---
// One bento tile in the capabilities grid size/flavour driven by the data
// One bento tile in the capabilities grid - size/flavour driven by the data
// (span: hero 2×2 flagship · wide 2×1 · full row · default 1×1).
import type { SkillGroup } from "../data/skills";
@@ -25,6 +25,13 @@ const spanClass =
{group.flag && <p class="tile__flag mono">{group.flag}</p>}
<h3 class="tile__title">{group.title}</h3>
<p class="tile__blurb">{group.blurb}</p>
{group.points && (
<ul class="tile__points" role="list">
{group.points.map((point) => (
<li>{point}</li>
))}
</ul>
)}
<ul class="tile__chips mono" role="list">
{group.items.map((item) => (
<li class:list={["chip", group.span === "hero" && "chip--lit"]}>{item}</li>
@@ -97,12 +104,12 @@ const spanClass =
position: relative;
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
font-size: 23px;
font-weight: 700;
color: var(--text-strong);
}
.tile--hero .tile__title {
font-size: 26px;
font-size: 30px;
}
.tile__blurb {
position: relative;
@@ -118,6 +125,39 @@ const spanClass =
max-width: 420px;
}
.tile__points {
position: relative;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
margin: 6px 0 0;
padding: 0;
font-size: 13.5px;
line-height: 1.55;
color: var(--text-dim);
}
.tile__points li {
padding-left: 18px;
position: relative;
text-wrap: pretty;
}
.tile__points li::before {
content: "";
position: absolute;
left: 0;
top: 0.55em;
width: 8px;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
border-radius: 1px;
}
.tile--hero .tile__points {
font-size: 14.5px;
gap: 12px;
max-width: 460px;
}
.tile__chips {
position: relative;
list-style: none;
@@ -141,9 +181,11 @@ const spanClass =
padding: 5px 10px;
}
/* bento collapses gracefully on narrow screens (grid goes 2-col then 1-col
in the parent; spans clamp automatically via grid auto-placement) */
@media (max-width: 720px) {
/* Below 1000px the parent grid is only 2 columns - so the multi-column spans
must collapse to full-width HERE, not at 720px. A `span 3` tile left active
in a 2-col grid forces a phantom 3rd column and collapses the 1fr tracks
(58px 58px 760px), squishing the first tile to ~130px in landscape phone. */
@media (max-width: 1000px) {
.tile--hero,
.tile--wide,
.tile--full {
+6 -3
View File
@@ -7,7 +7,7 @@ const projects = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/projects" }),
schema: z.object({
title: z.string(),
// The one-line "so what" shown on cards and the case-study header.
// The one-line "so what" - shown on cards and the case-study header.
outcome: z.string(),
summary: z.string(),
role: z.string(),
@@ -18,13 +18,16 @@ const projects = defineCollection({
order: z.number().default(50),
// Optional diagram key → rendered by the Diagram component (M3).
diagram: z.string().optional(),
// Optional cover art → /covers/<slug>.webp, rendered above the case header.
// Sells the story at a glance; the diagram still carries the architecture.
cover: z.string().optional(),
links: z
.array(z.object({ label: z.string(), href: z.string().url() }))
.optional(),
}),
});
// Blog write-only, schema-validated, pipeline-publishable.
// Blog - write-only, schema-validated, pipeline-publishable.
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
@@ -33,7 +36,7 @@ const blog = defineCollection({
summary: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
// Optional hero image a /blog/<slug>.webp in public/. Rendered on the post + as the card thumbnail.
// Optional hero image - a /blog/<slug>.webp in public/. Rendered on the post + as the card thumbnail.
hero: z.string().optional(),
heroAlt: z.string().optional(),
}),
+10 -11
View File
@@ -1,7 +1,7 @@
---
title: "SSO is a perimeter decision, not a login box"
date: 2026-06-23
summary: "Putting single sign-on in front of a fleet of self-hosted apps isn't about a nicer login screen it's about where your trust boundary lives, and how it fails."
summary: "Putting single sign-on in front of a fleet of self-hosted apps isn't about a nicer login screen - it's about where your trust boundary lives, and how it fails."
tags: ["authentik", "oidc", "sso", "security", "kubernetes"]
draft: false
hero: "/blog/authentik-sso-2fa.webp"
@@ -10,18 +10,17 @@ heroAlt: "A single central gate ringed by a second concentric seal radiates beam
---
Most write-ups treat single sign-on as a feature you bolt on: stand up an identity provider, point the
apps at it, enjoy one login. That framing misses the actual decision. SSO *moves your trust boundary*
every app now trusts one issuer, and every account is one credential away from all of them. Get it right
apps at it, enjoy one login. That framing misses the actual decision. SSO *moves your trust boundary* - every app now trusts one issuer, and every account is one credential away from all of them. Get it right
and you've hardened the whole estate at once. Get it wrong and you've built a single, central thing to lose.
Here's how I think about it after wiring an OIDC provider in front of a self-hosted fleet.
![Authentik SSO flow the browser authenticates against the identity provider over the front channel; each app validates tokens over an internal back channel](/diagrams/authentik-sso.svg?v=2)
![Authentik SSO flow - the browser authenticates against the identity provider over the front channel; each app validates tokens over an internal back channel](/diagrams/authentik-sso.svg?v=2)
## Enforce the second factor at the flow, not per app
The leverage is putting 2FA on the *authentication flow itself*, not on each application. Every app that
federates to the provider inherits it for free you can't forget to enable 2FA on app number nine, because
federates to the provider inherits it for free - you can't forget to enable 2FA on app number nine, because
app number nine never sees a password. Enrolment (TOTP / WebAuthn) is mandatory at the identity layer, and
the apps just receive an already-verified identity.
@@ -30,8 +29,8 @@ gets it.
## Keep a key under the mat
The flip side of "one issuer for everything" is that when the issuer is down or you fat-finger the auth
flow you can lock yourself out of *everything*, including the tools you'd use to fix it. So every critical
The flip side of "one issuer for everything" is that when the issuer is down, or you fat-finger the auth
flow - you can lock yourself out of *everything*, including the tools you'd use to fix it. So every critical
app keeps a **local break-glass admin** that bypasses SSO, and the recovery codes for 2FA enrolment live
offline.
@@ -43,19 +42,19 @@ Two traps ate real hours, and they're the same lesson wearing two hats:
- **A baked-in config file silently overrode environment variables.** I set the OIDC scopes via env, login
kept failing with *"missing fields: email"*, and the cause was a config file winning over the env. The
provider needs `openid email profile` explicitly set where the app actually reads it, not where you
provider needs `openid email profile` explicitly - set where the app actually reads it, not where you
assume it does.
- **In-cluster back-channels can't use the public hostname.** The browser hits the public login URL fine,
but the app's *server-side* token exchange runs inside the cluster, where split-horizon DNS doesn't
resolve the public name. The fix: point the back channel at the internal service address while the
browser-facing URL stays public.
Both say the same thing: SSO has two channels the one the user sees and the one the server uses and they
Both say the same thing: SSO has two channels - the one the user sees and the one the server uses, and they
don't live on the same network.
## The principle
## Design the concentration you just created
Centralising identity is a force multiplier, but it concentrates risk *by design*. So design the
concentration: enforce the strong factor at the boundary, plan the failure mode before you need it, and
remember that OIDC authenticates a *user* it doesn't, on its own, make a sensitive service safe to expose.
remember that OIDC authenticates a *user* - it doesn't, on its own, make a sensitive service safe to expose.
The login box is the easy part. The perimeter is the decision.
+78
View File
@@ -0,0 +1,78 @@
---
title: "The bastion is a pattern, not a box"
date: 2026-07-03
summary: "Admin credentials accumulate on whatever machine you happen to work from, until your daily driver is the real control plane of the fleet. A bastion fixes that, not by being special hardware, but by being the one place the keys live and the discipline that they never leave it."
tags: ["security", "operations", "homelab", "tailscale", "access"]
hero: "/blog/bastion-is-a-pattern.webp?v=1"
heroAlt: "A lone illuminated harbour pilot station on a dark sea, thin beams of cyan and magenta light reaching out to distant vessels - the charts and keys stay at the station, the ships only transit past it."
draft: false
---
Here's the uncomfortable default: your fleet's admin credentials live wherever you last needed them. A
kubeconfig on the laptop, another copy on the desktop, an age key pasted onto a third machine "just for one
decrypt", SSH keys sprouting wherever a clone was convenient. Nobody decided this. It accumulates.
Which means your **daily driver is the real control plane** - the machine that browses the web, opens email
attachments and runs whatever you installed last week is also the machine that can wipe a cluster. Lose it,
rebuild it, or compromise it, and you've done all three to the fleet.
The inversion is old and it still works: pick *one* point, put every key there, and make the rule that they
never leave it. Not a hardened appliance. A pattern.
<!-- DIAGRAM: one bastion node holding keys/contexts/repos, lines out to the clusters + git; laptops connect only THROUGH it -->
![One operator control point holding the keys, with everything driven through it](/diagrams/bastion-is-a-pattern.svg?v=1)
## Everything transits, nothing homes anywhere else
Think of a harbour pilot station. Every ship entering the harbour is guided by a pilot, but the charts and the
local knowledge never board the ships - they stay at the station, and the vessels transit past it. That's the
whole design: the dangerous knowledge has exactly one home, and traffic comes *to* it.
Mine is nothing special on purpose: a small Debian VM - four cores, no GPU passthrough, because a bastion needs
none. What makes it the bastion is what it holds. One kubeconfig with a context for every cluster. The
talosconfig for the OS layer beneath them. The SOPS age key that decrypts the fleet's secrets, sitting in
exactly one place at `~/.config/sops/age/keys.txt`. A checkout of every ops repo. And a toolchain pinned
deliberately - `talosctl` matched to the fleet's live Talos version, because a drifted client against a
production node is how apply-time surprises happen.
None of that is exotic. The pattern is the *singularity* of it: ask "where can the fleet be driven from?" and
the answer is one hostname, and auditing that answer takes a minute.
## The bastion is defined by what it refuses
The keys that live there matter less than the keys that were *refused*. When this box was stood up to take
over the operator role from an older machine - before that machine's wipe and rebirth as a GPU node - the
login credentials for my agent tooling were deliberately not copied across. Fresh auth on the new box beats a
credential that has now existed in two places, because the whole point collapses the moment "one place"
becomes "two, temporarily".
The stand-up also proved the pattern's diagnostic value. Every repo cloned fine, and then a push failed,
because the git remotes resolved through SSH host aliases that only existed on the *old* machine. Access had
been quietly welded to one particular computer for months, and nothing noticed while that computer was always
there. Consolidating forces the question: is this credential something the operator point holds, or something
one box happens to have? The fix was the pattern applied properly - a new key generated *on* the bastion,
registered once, never exported.
## The one that costs an evening
The bastion has to be reachable off-LAN, so it joined the tailnet as a subnet router, advertising its own
network. Here's the trap: it also sat *inside* a subnet another router already advertised, and bringing it
up with `--accept-routes` made it accept the overlay route *for its own LAN*. Local replies went into the
tunnel instead of out the NIC. ARP stayed healthy, every TCP connection died, and SSH to the box that holds
all the keys went dark.
The rule that falls out: a node on the only LAN **advertises routes but never accepts them** - it reaches that
network natively. And the recovery is itself a bastion lesson: you get back in *out-of-band*, through the
hypervisor console. The control point must sit on infrastructure you can still reach when the network lies.
## One place holds the keys
A bastion isn't a product you install; it's a property you enforce: **the set of places your keys exist has
exactly one member.** Any box can hold the role - a VM, a spare NUC, a cloud instance, and the role can move,
the way this one inherited it from a machine headed for a wipe. What never moves casually is the material
itself. Ships come and go all day.
The charts never leave the station.
*Live as a four-core Debian VM on the management LAN, driving a five-cluster Talos fleet and reachable over the tailnet as its second subnet router.*
+74
View File
@@ -0,0 +1,74 @@
---
title: "An agent should never hold the key it's using"
date: 2026-07-03
summary: "You want an AI agent that can actually do things - call APIs, touch real data. You also don't fully trust it. The resolution isn't a better sandbox; it's making sure the agent never possesses a credential at all. A broker holds the keys, mints short-lived capabilities, and gates every write behind a human. Here's the pattern."
tags: ["security", "ai-agents", "architecture", "zero-trust", "homelab"]
draft: false
hero: "/blog/broker-pattern.webp"
heroAlt: "An untrusted agent reaches through a sealed gate to ask; on the far side, a guardian holds a ring of keys the agent can never touch."
---
The previous post put untrusted code in a hardware-isolated VM, and ended on a caveat: isolation contains an
*escape*, but it does nothing about an agent **misusing a tool it was legitimately given**. If you hand an AI
agent a database credential so it can be useful, a single bad decision - a prompt injection, a confused chain
of reasoning - spends that credential. The sandbox did its job perfectly and you still got robbed, through the
front door you built.
So the real question isn't "how do I isolate the agent?" It's "how does the agent get work done *without ever
holding a key*?"
## The agent holds a capability, not a credential
The pattern is to put every credential, every tool, and every model endpoint **behind a broker**, and give the
agent only a *capability to ask*. The agent never sees a token. It calls the broker; the broker holds the real
credential, decides whether the request is allowed, and - if it is - performs the action itself and returns the
result. The key never leaves the broker.
That one inversion changes the threat model completely. A fully compromised agent can now do exactly one thing:
**ask**. And asking is answered by something it can't reach, can't impersonate, and can't bypass.
<!-- DIAGRAM: untrusted sandbox -> cred-broker (mint capability) -> mcp-broker (verify + run tool with held cred) -> human-approval gate -> downstream. Agent never touches the credential. -->
![The two-tier broker flow: capability in, action out, credential never exposed](/diagrams/broker-pattern.svg)
## Two clusters, two brokers
The trust boundary is physical, not just logical. The **untrusted executor** (the Kata sandboxes) lives on one
cluster; the **trusted tier** (the brokers, the model gateway, the real credentials) lives on a *separate*
cluster. A total compromise of the executor still can't reach the brokers' secrets except across a policed
network link - there's no shared kernel, no shared API server, nothing to pivot through.
On the trusted side there are two brokers, deliberately split:
- A **credential broker** validates the sandbox's identity (a short-lived, signed token unique to the task) and
mints a **capability** - a cryptographically signed, scoped, single-use, expiring grant. Not a credential. A
*permission to ask for one specific thing*.
- A **tool broker** takes that capability, verifies the signature, the scope, and the one-time nonce, and only
then runs the requested tool - using a credential *it* holds. The result comes back; the credential doesn't.
## A human gates every write
Reads are one thing. For anything that *changes the world* - creating, deleting, sending - the broker doesn't
just decide on policy. It **stages** the action and pings a human: an Approve/Deny prompt on my phone, carrying
a one-time token bound to the exact task, method, and arguments. Tap approve and it executes; tap deny, or
ignore it, and it doesn't. The gate is **fail-closed**: a timeout is a denial, a replayed token is rejected, an
unknown method is rejected. The default, always, is *no*.
The load-bearing principle underneath all of it: **enforcement that has to survive a compromised agent lives at
the broker, never at the agent or the orchestrator.** Approval, scope, budgets, write-authority - none of it
lives anywhere the agent's reasoning can touch. The agent can be wrong, jailbroken, or outright hostile, and the
worst-case is still just *a request that gets refused*.
## Useful and safe at the same time
It's tempting to think you have to choose: give the agent real power and accept real risk, or lock it down so
hard it can't do anything. The broker pattern is how you get both. The agent is *useful* - it can call real
tools against real systems. It's *safe* - it never holds a key, every write waits on a human, and the moment
something goes wrong, the blast radius is a denied request, not a spent credential.
Run agents like you'd run any other untrusted input: assume it's compromised, and make sure that assumption is
*boring*.
*Live as the trusted tier of a two-cluster AI-agent platform: a credential broker and a tool broker holding the
keys, a phone-based human-approval gate on every write, and an agent that - by construction - never sees a
secret.*
@@ -0,0 +1,54 @@
---
title: "The cert that couldn't see its own proof"
date: 2026-07-03
summary: "Let's Encrypt DNS-01 posted its proof to the public internet, and then checked for it through the LAN's split-horizon DNS, which will never carry it. The fix is one line: make the self-check look where the CA looks, not where you live."
tags: ["tls", "dns", "cert-manager", "lets-encrypt", "split-horizon"]
hero: "/blog/cert-couldnt-see-its-proof.webp?v=1"
heroAlt: "A sealed structure split by a translucent wall: a glowing proof-token sits in the open on the outside where a distant beam finds it, while an identical vantage on the inside looks toward the same spot and sees nothing."
draft: false
---
Here's the uncomfortable default: cert-manager checks its own homework **through whatever DNS the cluster hands it**. On a normal network that's fine - the cluster's resolvers see the same internet everyone else does. On a split-horizon network, they don't. Your internal DNS is authoritative for the domain, answers first, and knows nothing about the record you just published to the world.
So the DNS-01 challenge does something quietly absurd. cert-manager writes the `_acme-challenge` TXT record to Cloudflare, and it lands. `dig @1.1.1.1` returns the token. Let's Encrypt could validate it right now. But before asking the CA to look, cert-manager runs a *self-check*: it resolves the record itself, through cluster DNS, through the Pi-hole that owns `bztmon.org` internally and has no such TXT record. Nothing comes back. So it waits, and checks again, and loops on `"not yet propagated"` - forever.
It's posting a letter into the public postbox, then walking back inside to check the hallway mail slot for it. The letter is out there. The postman can see it. You're looking in the one box it will never reach, and concluding it hasn't been sent.
<!-- DIAGRAM: split-horizon DNS-01 - cert-manager writes the TXT to Cloudflare (public path, record live), Let's Encrypt validates via public DNS, but the self-check arrow loops through the internal Pi-hole which returns nothing; the fix arrow reroutes the self-check to 1.1.1.1/8.8.8.8 -->
![The self-check resolving through the wrong horizon while the CA sees the record](/diagrams/cert-couldnt-see-its-proof.svg?v=1)
## Look where the examiner looks
The fix is one Helm value, and it encodes the whole lesson:
```yaml
dns01RecursiveNameservers: "1.1.1.1:53,8.8.8.8:53"
dns01RecursiveNameserversOnly: true
```
That points the self-check at public resolvers - the same vantage Let's Encrypt validates from - instead of the cluster's own view of the world. The moment cert-manager checks the postbox the postman actually collects from, the challenge that had been pending for an age clears in seconds. No record changed. No DNS changed. Only the *observer* moved.
One footnote if you run default-deny egress: the check now leaves the cluster on port 53, so `world:53` has to be allowed for the cert-manager pods. Here it already was, courtesy of the same policy that lets cloudflared out.
## Two proofs, one postbox
The same challenge has a second way to hang, and it looks identical from the outside. Request a certificate for both the wildcard `*.bztmon.org` *and* the apex `bztmon.org`, and Let's Encrypt opens two authorisations - both of which validate at the **same** record name, `_acme-challenge.bztmon.org`, with *different* tokens. cert-manager's Cloudflare solver dutifully writes one, then the other solver overwrites it, then the first writes it back. Two letters fighting over one envelope, each self-check finding the other's contents. Both loop on `"not yet propagated"`, and neither is a propagation problem.
The fix is to stop asking for both: request the wildcard only, and drop the apex SAN unless you serve the bare domain. This one ate a real evening on robin before the pattern was obvious.
## A permission failure in a propagation costume
The third trap doesn't even mention permissions. A Cloudflare API token with `Zone:DNS:Edit` alone *looks* sufficient: it can edit records, what more could a DNS solver want? But cert-manager first has to enumerate zones to find the zone ID, and that needs **`Zone:Zone:Read`**. Without it, the failure surfaces not as `403` but as - you guessed it - `"not yet propagated"`.
Add Zone:Read to the token at Cloudflare and it unblocks instantly. The token string doesn't change, so no secret rotates; the fix never touches the cluster at all.
Three different root causes. One identical symptom. That's the real menace of this error message: it names the *last observation* ("I can't see the record"), not the reason.
## A check is only worth its vantage point
A self-check is only worth what its vantage point is worth. If your verifier stands outside - a CA on the public internet, a monitor beyond the LAN, an auditor reading the published copy - then a check that observes from *inside* isn't a weaker version of the truth. It's a different truth, and it can disagree indefinitely.
So when a system checks its own work before presenting it, ask one question first: does it look from where the judge will look? Check the postbox the postman collects from, not the mail slot in your own hallway.
*Bit the media-gateway wildcard on elfastc, 2026-07-02 - cert-manager v1.19.5 issuing `*.bztmon.org` via Cloudflare DNS-01, on a fleet where the internal Pi-hole owns the zone and public resolvers hold the proof.*
+52
View File
@@ -0,0 +1,52 @@
---
title: "The workbench gets a clone of the brain, never the keys"
date: 2026-08-02
summary: "A browser IDE you can reach from the couch or a phone is a legitimate want, and the obvious build, hosting it where the keys already live, quietly parks the whole estate behind a browser tab. Price the stolen session first, then hand the new surface copies of everything and originals of nothing, so the worst case is a git revert instead of a lost fleet."
tags: ["security", "developer-experience", "sso", "kubernetes", "homelab"]
draft: false
hero: "/blog/clone-not-keys.webp"
heroAlt: "A luminous reading room of copied pages, thin circuit traces running back to a sealed vault where the original documents rest untouched"
---
You want your development environment everywhere. VS Code in a browser tab, reachable from the couch on the LAN and from a phone across the mesh VPN, with git and the daily tooling already inside. It's a legitimate want - it's how odd half-hours turn into finished work.
And the first instinct is obvious: host it on the operator box. That machine already has everything - every repo cloned, every context wired, every login warm. One container, one hostname, done by the weekend.
Read that instinct back slowly, because of what it actually proposes. The operator box is the one machine that can drive the entire fleet: the master decryption key for every secret, the admin kubeconfigs, the SSH keys. "Host it there" means parking all of that behind a browser tab - one stolen session away from anyone who ever finds the door. I've argued before that the fleet's keys should exist in exactly one place; this was the first time a convenience asked to move in with them. The design that survived the red-team gives the convenience something else entirely: a **clone** of the operator's brain - the repos, the tooling, the logins, furnished fresh. Never the keys.
<!-- DIAGRAM: browser + phone -> authentik proxy (every path 302s to login, no carve-outs) -> code-server pod [own volume: cloned repos, own git key, own logins - NO age key] -> egress only DNS + gitea SSH + 443; the bastion (age key, admin kubeconfigs, SSH keys) sits outside the frame, unreachable. -->
![One guarded door to a pod that holds copies and its own scoped identity; the operator's keys sit outside the frame entirely](/diagrams/clone-not-keys.svg)
## Price a stolen session before you build the surface
The question that shaped this build wasn't "how do we secure it?" It was blunter: assume the worst has already happened - the session is fully stolen, an attacker sitting in the IDE as you, and write down what they now hold.
Against the first sketch, the one that ran on the operator host, the red-team returned three separate critical findings, all of them consequences of proximity to that host's keys. Each would have needed its own mitigation, its own monitoring, its own upkeep. Then the design moved to a disposable pod that inherits nothing from the operator host, and all three findings died in the same moment. Not mitigated - *deleted*. There was nothing left to steal along those routes, because nothing had been put there.
That is the cheapest security work I have ever done: three critical findings closed by a relocation, before a single line of configuration existed. Blast radius is a design input. Treat it as an audit output and you'll be retrofitting forever.
## Clone everything, inherit nothing
Think of the workbench as a reading room attached to an archive. A reading room is genuinely useful - every document laid out on the desks, annotated, workable, open late. But the pages on the desks are photocopies. The originals stay in the vault, and no reader's card opens it.
The pod starts empty. On first run I furnished it the way you'd stock a reading room: cloned the repos *into* it, generated a fresh git key *inside* it - registered in its own name, revocable on its own, and signed in to the daily tooling so those logins persist on the pod's own volume. Nothing is mounted from the operator host. Nothing is inherited. And the one thing deliberately kept out is the master secrets key - the key that turns the encrypted blobs in those repos into live fleet credentials. The workbench holds a copy of every document; the pages that matter are ciphertext to it.
One call in there deserves honesty, because it was a real trade. That git identity has account-level access: the workbench can push, including to the repo the fleet reconciles from. That is genuine power in a convenience surface, and I granted it with eyes open - partly because the door in front of it is layered, and mostly because of what the worst case *is*. A hostile push is loud. It lands in git history, attributed and timestamped, and `git revert` undoes it. Set that against the alternative worst case - silent possession of every credential in the estate, and "attacker can push to some repos" is a failure you recover from, audit, and learn from. The other one is a failure you rebuild from.
## Put everything behind one proven door
The front of the workbench is SSO-only, through the identity provider's proxy, and *everything* means everything. The route sends 100% of paths through the proxy, with no carve-outs, because the obvious carve-out is a hole: the terminal speaks over a WebSocket, and a WebSocket path excused from authentication is an unauthenticated shell. So it was proven, not assumed - probe the root unauthenticated, probe the terminal's WebSocket, probe the static assets and the health endpoint, and every one of them 302s to the login page.
Behind the SSO sits the application's own password, injected from the secrets manager rather than living in any repo. Belt behind braces: a bug in the proxy still lands on a locked application.
Under both sits a default-deny network policy. The pod can reach the git server, DNS, and its API endpoints over 443 - nothing else. That floor is real enough to bite: the git server's own policy had to grant the workbench an ingress seat by name, and the very first clone hung until it did. The hang *was* the policy working.
Just as important are the doors that don't exist. A direct LAN bind and a separate mesh-VPN serving path were both sketched, and both cut - the one guarded door already serves the couch and the phone alike. Every additional entrance is an additional thing to prove, forever.
## Work backwards from a total compromise
Every new convenience surface - a browser IDE, a remote dashboard, a sync agent - should be designed backwards from one number: what a *total* compromise of it yields. Compute that before the surface exists, while the answer is still a design choice. Then engineer it down: clones instead of originals, an identity scoped to the surface and revocable without ceremony, doors layered so no single failure is enough. Only when the worst case is something you could live through do you get to enjoy the convenience, and then you get to enjoy it completely, from any couch you like.
A reading room earns its comfort by holding photocopies. You can deface every page on every desk and the archive survives, because the originals were never in the room.
*Live at code.bztmon.org: a code-server pod on one Talos node, its home a 20Gi volume of cloned repos, its own git key and its own logins - behind an Authentik proxy that 302s every path, WebSockets included, to login, above a default-deny network policy. The fleet's age key has never entered it.*
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: "Draft: notes on air-gapped registry mirroring"
date: 2026-06-17
summary: "Work in progress this draft should never appear in the production build or the RSS feed."
summary: "Work in progress - this draft should never appear in the production build or the RSS feed."
tags: ["draft", "registry"]
draft: true
---
+29 -17
View File
@@ -8,18 +8,20 @@ heroAlt: "A GPU glows behind a sealed checkpoint gate while a waiting pod-orb is
---
The most common way a GPU workload fails at the edge isn't the model, the driver, or the
network. It's timing. Kubernetes is eager it will happily schedule your inference pod the
moment a node is `Ready`, which is often *before* the NVIDIA device plugin has advertised
`nvidia.com/gpu`. The pod starts, can't see a GPU, crash-loops, and now your rollout is
poisoned across the fleet.
The most common way a GPU workload fails at the edge isn't the model, the driver, or the network.
It's timing. Kubernetes is eager - it will happily schedule your inference pod the moment a node
reports `Ready`, which is often *before* the NVIDIA device plugin has advertised `nvidia.com/gpu`.
The pod starts, can't see a GPU, crash-loops, and now your rollout is poisoned across the fleet,
on boxes nobody is standing next to.
The fix is to make readiness explicit. Don't trust node-`Ready`; gate on the GPU.
Node-`Ready` answers the wrong question. It says the kubelet is up. It says nothing about whether
the one piece of hardware your workload exists to use is actually there yet. So stop trusting it:
make GPU readiness explicit, and gate on that.
## Gate the schedule, not just the start
## Gate the schedule, then gate the start
A resource request is the first line — a pod that *requests* a GPU won't schedule until the
plugin advertises capacity:
The first gate is free - a resource request. A pod that *requests* a GPU won't schedule until the
device plugin advertises capacity:
```yaml
resources:
@@ -27,9 +29,10 @@ resources:
nvidia.com/gpu: 1
```
But on a single-GPU edge node that's recovering from a reboot, you still want a hard check
before the workload does anything expensive. An init container that blocks until the device
is real keeps the main container honest:
That handles the common case. But on a single-GPU edge node recovering from a power cut, there's a
window where the plugin has advertised the device and the driver is still finding its feet - and
you don't want an expensive model load to be the thing that discovers it. So the second gate is an
init container that blocks until the device is demonstrably real, and fails loudly if it never is:
```bash
#!/usr/bin/env bash
@@ -45,11 +48,20 @@ echo "GPU never became ready" >&2
exit 1
```
Two gates, two failure modes closed: the scheduler can't place the pod before capacity exists, and
the workload can't start before the hardware answers. Note the bound - an init gate that waits
forever isn't a gate, it's a hang. Two and a half minutes, then fail loud and let the platform
retry. Fail-closed, never fail-quiet.
## Why this is the win
Once readiness is gated, the whole class of "pod started before the GPU" failures disappears
— and it disappears *the same way on every node*. That consistency is the real prize at the
edge, where no one is standing next to the box to nurse a bad rollout.
Once readiness is gated, the entire class of "pod started before the GPU" failures disappears, and it disappears *the same way on every node*. That consistency is the real prize at the edge.
A fix that requires a human to notice, shell in, and nurse a bad rollout doesn't scale past the
first dozen sites; a gate that makes every node converge identically after every reboot does.
The principle generalises: at the edge, **design the dependency, don't hope for it**. The GPU
is just the first dependency worth gating; egress paths and model artifacts are next.
## Design the dependency, don't hope for it
At the edge, **design the dependency - don't hope for it**. Anything your workload cannot run
without deserves an explicit, bounded, fail-loud gate between it and the scheduler's optimism.
The GPU is just the first dependency worth naming; egress paths and model artifacts are next,
and they want the same treatment.
+60
View File
@@ -0,0 +1,60 @@
---
title: "The best migrations delete more than they install"
date: 2026-08-02
summary: "Nobody chose our network stack - it accreted one default at a time, and every component was one more thing to version, patch, debug and alert on. Landing on Cilium wasn't an adoption; it was a demolition schedule, and the stack left standing is smaller than the one we started with."
tags: ["cilium", "kubernetes", "networking", "architecture", "homelab"]
draft: false
hero: "/blog/landing-on-cilium.webp"
heroAlt: "An accreted house of circuit-traced extensions dissolves into particles, leaving one clean glowing frame standing"
---
Ask why the fleet ran flannel and there was never an answer worth writing down. Nobody chose it. Talos bundles flannel, so flannel ran. Kubernetes ships kube-proxy, so kube-proxy ran. LoadBalancer services need something on the LAN to answer for their addresses, so MetalLB moved in. HTTP needed terminating, so ingress-nginx went up, and later Traefik replaced it - on one cluster, Traefik still answers on ingress-nginx's old IP, the fossil of a hop nobody remembers deciding.
That's how most platform stacks are built. Not designed - accreted, the way an old house grows. A lean-to here, a carport there, each extension the quickest answer to the question of the day. And every extension arrives with its own roofline: a version to pin, a changelog to read, failure modes to learn, alerts to write.
This week the fleet finished landing on Cilium, and the honest record of that landing is the point of this post. It wasn't a product evaluation. It wasn't an adoption. It was a demolition schedule, and the stack left standing is smaller than the one we started with.
<!-- DIAGRAM: before/after columns on one cluster. Left (before): five separately versioned boxes - flannel (CNI), kube-proxy (service path), MetalLB (VIP announcement), Traefik/ingress-nginx (HTTP), and a dashed "NetworkPolicies - authored, unenforced" box - each with its own version tag. Right (after): a single Cilium box carrying CNI + eBPF service path + LB-IPAM/L2 announcements + Gateway API + enforced policy + Hubble flows, shipped inside the Talos machine document. The transition arrows are strike-throughs (deletions), not installs. -->
![Before and after: five separately versioned components collapse into one engine - by deletion](/diagrams/landing-on-cilium.svg)
## Count the rooflines, not the features
The starting shape, across a fleet of single-node Talos clusters: flannel carrying pod traffic, kube-proxy rewriting every service address, MetalLB answering ARP for the LoadBalancer VIPs, Traefik terminating HTTP. Plus a set of network policies that were authored, committed and completely inert - flannel can't enforce them, so the plans existed and nothing was ever built to them.
Four separate projects for four concerns, and a fifth concern going unmet. Each project a chart or a DaemonSet with a version, a CVE feed, an upgrade cadence, and its own distinct way of ruining an evening. None of them wrong individually. All of them defaults, and a default is a decision someone else made, for a fleet they've never seen.
What started the demolition wasn't "Cilium is better than flannel". It was noticing that one engine could carry all five concerns - the pod network, the service path, the VIP announcements, the policy enforcement, the flow visibility, and that one cluster in the fleet already ran that engine, a quiet standing proof. Four rooflines could become one.
## Swap the frame under an occupied house
Stage one, June: replace the CNI in place, on live clusters, no rebuilds. Per cluster the sequence is short. Set `cni: none` in the machine configuration, one reboot, install Cilium 1.19 with a pinned set of values, delete the flannel DaemonSet, then restart every non-hostNetwork pod so it leaves flannel's address management for Cilium's. Order the clusters lowest blast radius first; the control-plane hub goes last, once the procedure has become boring.
Two disciplines mattered more than the sequence. First, confirm the node rebooted at all - the apply returns immediately and the API often answers before the machine has cycled, so trust the uptime counter, not reachability. Second, those inert policies stop being inert the moment Cilium arrives: lines on a plan under flannel, enforced at the instant of cutover. Audit every allow-list *before* migrating the cluster that holds it.
Four live clusters converted in a day. Zero rebuilds, zero data loss, and the fleet verified green afterwards - 98 of 98 GitOps applications healthy. Then stage two, the reason enforcement mattered at all: a default-deny floor in every namespace, rolled fleet-wide with the observe-first method that has [its own post](/blog/observe-first-deny-second) - watch the real flows, then deny everything else ([the why lives here](/blog/trust-nothing)).
## Then pull the extensions down
Stage three, July - finished this week. With Cilium carrying pod traffic everywhere, the remaining structures came down one by one.
kube-proxy went first. Cilium's eBPF datapath took over service routing entirely, reaching the Kubernetes API through Talos's KubePrism endpoint on `localhost:7445`, after which the kube-proxy DaemonSet was deleted outright. Then MetalLB: Cilium's LB-IPAM handed out the same VIPs from the same pools, L2 announcements answered the same ARP queries, and every service kept its address through the swap. One reboot per node. On the media node, Cilium's Gateway API absorbed the ingress role as well - one more structure gone. MetalLB is now extinct across the fleet. Not deprecated, not legacy-mode: extinct.
The last demolition was managerial. The CNI no longer belongs to Helm at all - it ships as a Talos inline manifest, which means the network lives inside the machine's own configuration document. A rebuilt node comes up with its network the way it comes up with its kernel: because the document says so. Zero Helm release secrets remain anywhere in the fleet.
## Two structures fought back
There is always a price, and this one came due twice.
The first trap is the nastiest kind of failure: the change that succeeds and does nothing. Deleting Helm's release record orphans the resources, but it does *not* release Helm's per-field ownership inside them. So the flip to the eBPF datapath applied cleanly, the node rebooted on schedule, and `KubeProxyReplacement` still read `False`, because a config field Helm still owned silently refused the new manager's apply. No error. Nothing in the diff. It fired on every single cluster we flipped, so it's now a standard step rather than a contingency: steal the whole manifest's ownership server-side - ```
kubectl apply --server-side --force-conflicts --field-manager=talos -f cilium-rendered.yaml
``` - then verify the config changed before believing anything else.
The second was musical chairs with the VIPs. Mid-swap on one cluster, a service without a pinned address grabbed another service's freed IP the instant the old announcer released it - the LLM backend ended up sitting on the ingress controller's address. The fix is an ordering rule: pins first. Land the address pins while the old announcer is still alive, confirm they hold, and only then retire it. Two commits, in that order, every time.
## Count what you removed, not what you added
Consolidation is usually sold on the new tool's feature list. That's the wrong ledger. The value of landing on one engine wasn't what Cilium added - it was what the landing let us remove: a CNI, a service proxy, a VIP announcer, and on one node an ingress controller, each of which had been a version to pin, a feed to watch, an alert to tune, a way to be paged. Policy enforcement and flow observability never needed projects of their own; they came up with the same engine, side effects of the consolidation rather than line items on it.
Every structure you tear down is one that can never fall on you. Judge the next migration by its demolition list.
*Live across six single-node Talos clusters: Cilium 1.19.4 shipped as a Talos inline manifest, the eBPF datapath where kube-proxy used to be, LB-IPAM answering every VIP MetalLB once held, and Hubble watching the flows on every cluster.*
+80
View File
@@ -0,0 +1,80 @@
---
title: "My servers don't have SSH, and that's the feature"
date: 2026-07-14
summary: "Every box in this fleet runs an OS with no shell, no package manager, and no SSH daemon - the entire machine is an API with a declarative config. It sounds like giving up control. It's the opposite: you can't drift what you can't touch."
tags: ["talos", "kubernetes", "immutable-infrastructure", "security", "homelab"]
draft: false
hero: "/blog/no-ssh.webp"
heroAlt: "A sealed obsidian machine with a single structured API port of light"
---
The first thing everyone asks about Talos Linux: how do you get in? You don't. There is no SSH daemon to
connect to, no shell waiting behind it, no package manager if you got there, and no login even at the
physical console. The operating system boots, runs Kubernetes, and answers exactly one thing: a mutual-TLS
gRPC API. Six machines in this fleet, and not one of them is a place I can *visit*.
That sounds like a limitation you'd tolerate for the security. It took about a week of running it to
realise it's the point.
## A server you can't visit is a server you can't drift
Every hand-run command on a traditional box is an unrecorded change: a config edited to test something, a
package installed during an incident, a daemon restarted with a flag nobody wrote down. None of it is in
git. All of it is load-bearing by next month. Configuration drift isn't a failure of discipline - it's the
*inevitable* product of machines that accept hands.
Talos removes the hands. The entire machine - disks, network interfaces pinned by MAC, kernel modules,
registry mirrors, even the manifests the cluster boots with - is one declarative document, applied through
the API, versioned in git. There's no side door through which an undocumented change can arrive, because
there's no door. The machine config isn't documentation *of* the machine. It **is** the machine.
<!-- DIAGRAM: left - a traditional server as a house with many doors (ssh, console, package manager), arrows of drift entering; right - a sealed Talos block with one structured API port, a signed config document flowing in -->
![Many doors and drift, versus one API and a document](/diagrams/no-ssh.svg)
## Operating through the keyhole
Day-two work changes shape. Logs, service status, process lists, even packet capture arrive through typed
API calls, not a terminal session, and every mutation is an *apply*: edit the document, run a dry-run diff
that says precisely what will change and whether it costs a reboot, then commit it. The dry-run gate is the
quiet superpower - on a machine you can't shell into, "what exactly will this do?" stops being a guess and
becomes an answer the API owes you *before* anything moves.
This week put that shape under load. Three of the six nodes had their network datapath swapped - kube-proxy
out, Cilium's eBPF replacement in, and their registry credentials rotated, one reboot each, entirely
through the API. Nobody logged into anything, because there is nothing to log into. The credentials never
touched a live machine either: they're substituted into the document at render time from an encrypted file
and arrive as configuration, not keystrokes - there is no terminal to mistype them into.
Two habits from that work show what keyhole operation feels like. After an apply that costs a reboot, the
API answers again *before* the machine has cycled - the door reopening proves nothing. So you read
`/proc/uptime` through the API and demand a number seconds old; the check has caught a node still showing
413,223 seconds of uptime with the apply long returned. You don't trust the door. You read the building's
own clock through the keyhole. And before calling a node good, you have it pull a container image through
that same API - the machine exercises its new registry credential end-to-end while you watch from outside.
When something's genuinely wrong, the recovery isn't archaeology on a mutated filesystem - it's
reconciliation: re-apply the known-good document, or in the worst case rebuild the node from it in minutes.
The machine has no state worth rescuing *because nothing was ever hand-placed on it*. Pets die of unknown
illnesses; documents get reprinted.
## The honest trade
The console is gone as a *control* surface, not as a *truth* surface, and that distinction still bites. A
node once dropped off the network in a way that looked, from every remote signal, like a total lockup. The
physical screen told a different story: the box was alive and healthy, only its network path had wedged.
The lesson isn't "you need a shell" - a shell would have shown the same thing more slowly. It's that
firsthand evidence still outranks remote inference, and an API-only fleet needs its operators to remember
the difference on the day it matters. The same lesson came back this week when a node went dark at layer 2:
the fix was a screwdriver, not a session - no SSH daemon answers on a dead NIC either.
## Control is the machine matching its document
Control isn't the ability to touch a machine - it's the guarantee that the machine matches a document you
trust. Every interactive door a server offers is a place where reality and the record can quietly diverge,
and everything that makes fleets miserable lives in that gap. Seal the doors, apply the document, keep the
diff. The machine you can't log into is the only one whose state you truly know.
*Live across a six-node Talos fleet: machine configs generated and versioned in git, applied over mTLS with
dry-run gates, nodes rebuilt from documents when hardware moves. Three of the six had their network datapath
swapped and their registry credentials rotated this week - one reboot each, entirely through the API. No SSH
key to any of them exists, because there is nothing for it to open.*
@@ -0,0 +1,96 @@
---
title: "Observe first, deny second"
date: 2026-07-14
summary: "Everyone writes network policy from the architecture diagram, and the diagram is always wrong. The only allowlist that survives contact with production is one written from the flows you actually watched - applied out-of-band, proven enforcing, and only then handed to GitOps."
tags: ["security", "networking", "cilium", "hubble", "kubernetes", "gitops"]
draft: false
hero: "/blog/observe-first-deny-second.webp"
heroAlt: "A dark reserve at night, instrument beams tracing animal paths before any fence exists"
---
Here's the uncomfortable default: when it's time to lock a namespace down, almost everyone opens the
architecture diagram and starts writing allow rules from it. This talks to that, that talks to the database,
done. The diagram is confident, tidy, and wrong. It omits the probe traffic, the DNS hop, the controller's
back-channel to the API server, the one call some library makes that nobody documented.
Policy written from a diagram *feels* like engineering. It's fiction with enforcement attached - and
enforcement doesn't care that the fiction was well-intentioned. It drops the flow anyway, in production,
at whatever hour the flow next happens.
I made the case for the default-deny floor itself in [the trust-nothing post](/blog/trust-nothing); this is
the other half: how you roll it across a live fleet, namespace by namespace, without an outage. The inversion is
simple. Don't fence first and see what starves. **Survey the reserve, then build the fence.**
<!-- DIAGRAM: the loop - Hubble observe -> write allowlist from evidence -> apply out-of-band -> verify enforcing + zero drops -> commit to git -->
![The observe -> deny -> verify -> commit loop](/diagrams/observe-first-deny-second.svg)
## Survey before you fence
A ranger doesn't fence a reserve from a map. They track the actual animals for a season - where they drink,
which paths they take at night, because the fence has to leave every real path open and close everything else.
Hubble is that season of tracking. Before a single policy exists, watch the namespace's real flows - every
connection, source and destination identity, port. That record *is* the allowlist. Not the docs, not your
memory of the architecture, not the diagram. Every rule you write should point at a flow you watched happen.
Start with the lowest-risk namespaces and leave the control plane for last: mis-fence a leaf app and one thing
breaks; mis-fence the plane that runs deploys and everything does.
## Build the fence where you can tear it down
Apply the policy trio (the default-deny plus its allows) **out-of-band** with `kubectl`, not through git.
This is deliberate, and it's the step people skip. The GitOps reconciler doesn't know the policy exists, so
it can't fight you over it, and rollback is one `kubectl delete`: instant, no commit, no sync wait. Push the
policy through git first and you've inverted your own safety: selfHeal means the reconciler restores whatever
you delete, in seconds, while you stand there believing you rolled back.
Out-of-band is a temporary state, not a destination. It's the fence held up with clamps while you check the
gates.
## Prove it bites, prove nothing bleeds
Two verifications, and both are mandatory because each one lies without the other.
First, prove the policy enforces at all. On Cilium 1.19 a policy can be **accepted but inert** - it passes a
server-side dry-run, sits in the cluster looking correct, and enforces nothing. A policy that looks live and
isn't is worse than no policy: it changes what you believe without changing what the network does. Read the
live object's status conditions and demand `Valid=True`, then watch a forbidden connection get dropped. "It
applied cleanly" and "it's enforcing" are different sentences.
Second, prove nothing legitimate is bleeding. Back to Hubble - zero drops on real flows, and the real consumer
path exercised end-to-end, not just a curl from your own shell.
Only when both hold does the policy go to git. The reconciler adopts it, out-of-band ends, and the rollback
story changes shape: from here, undo means `git revert`, because selfHeal now defends the policy as hard as
it would have fought your rollback.
The sternest test so far wasn't a rollout at all: the fleet's datapath was swapped out underneath the
policies - kube-proxy replaced by Cilium's eBPF kube-proxy-replacement on the last three clusters, one reboot
each, and afterwards every namespace's floor re-verified enforcing: 30/30, 32/32, 27/27 `Valid=True`, zero
legitimate drops. The fence held while the ground under it was replaced.
## The paths nobody draws
Three flows exist in every namespace and appear on no diagram. Omit any one and you pay:
- **DNS: allowed by label, never by IP.** Pin the resolver's ClusterIP into a rule and it never matches at
all: the address is rewritten to a backend pod before policy judges the flow. Select `k8s-app: kube-dns`
and it holds forever.
- **The kubelet's probes.** Health checks arrive from the **host** entity on *every* workload. Forget the
allow and your pods go NotReady the moment the deny lands, the fence starving the animals it was meant
to protect.
- **API-server egress.** Every controller and operator talks to the Kubernetes API constantly. A controller
that "manages X" usually manages it *through* the API server, so this one allow often covers its whole
lifecycle.
## Write enforcement from evidence, not assumption
The method generalises past network policy. Any control that turns assumption into enforcement - firewall,
RBAC, admission - is only as honest as the evidence it was written from. Observation first, out-of-band while
unproven, permanent only after it's earned it.
Policy written from observation is engineering. Policy written from a diagram is fiction, and production is
where fiction gets fact-checked.
*Rolled out across all six Talos clusters on Cilium 1.19 - every namespace surveyed with Hubble before its
deny landed, the control plane last, zero outages; the floor re-verified enforcing after this week's
kube-proxy->eBPF datapath swap.*
@@ -0,0 +1,58 @@
---
title: "SSO is for people, not for machines"
date: 2026-08-02
summary: "When an app grows a human face on an interface machines also depend on, one auth layer over everything either breaks the machines or quietly weakens the human door to let them through. The pattern that holds: a single identity-provider proxy in front of every human door, while machine paths keep their own scoped credentials - route by audience, not by app."
tags: ["security", "sso", "authentik", "kubernetes", "homelab"]
draft: false
hero: "/blog/one-doorman-many-doors.webp"
heroAlt: "A glowing reception desk in a dark circuit-lined lobby signs streams of visitor light through many doors, while courier light-trails slip through keyed loading docks around the building's edge"
---
Sooner or later, a machine-facing service grows a human face. The container registry every node in the fleet pulls from ships a web UI. The development environment becomes a browser tab with a live terminal in it. Now there are people arriving at hostnames that machines also depend on, and the tempting move is one auth layer over the whole thing. Put the entire host behind single sign-on. One rule, no exceptions, done.
Do that to a registry and the cluster stops pulling images the same afternoon, because a container runtime cannot answer an interactive login page. The failure after that one is quieter and worse: you see the machines breaking, so you loosen the layer to let them through - an exception here, an anonymous path there - until the human door is weaker *because* the machines needed in.
The answer isn't a cleverer single layer. It's a split: **route by audience, not by app**. People get one doorman. Machines get keyed doors of their own, nowhere near the desk.
<!-- DIAGRAM: One hostname, two audiences. Left: human browsers converge on a single identity-provider proxy outpost (the doorman), which fronts multiple app doors - registry web UI and browser IDE - each app a provider bound to the SAME outpost. Right: machine clients (node container runtimes, CI) hit the registry's /v2 path directly, split off at the gateway, presenting their own scoped read-only credential - never touching the SSO layer. Show the gateway making the split: /v2 -> registry direct, catch-all -> outpost. Cyan #0c8fce human path, magenta #c026d3 machine path, violet #7c3aed app boxes, on the light card. -->
![One host, two doors - the gateway splits by audience: browsers route through the identity-provider outpost, while the registry's /v2 machine path goes direct with its own scoped credential](/diagrams/one-doorman-many-doors.svg)
## A doorman is for doors that can't answer for themselves
The house rule comes before anything else: when an app speaks OIDC natively, wire it natively. The app authenticates its own users against the identity provider and stays directly reachable, which matters, because its existing machine clients (a credential vault's mobile app, a git CLI pushing over HTTPS) keep working untouched. Native wiring routes by audience for free.
A proxy outpost (an authenticating reverse proxy operated by the identity provider) is for the remainder: apps with no serious auth story of their own, or surfaces you'd never trust to defend themselves. Two tenants in this fleet fit that bill. A registry web UI, whose built-in auth is a static htpasswd file: a fine key for a machine, a poor front door for a person. And a browser IDE: an editor in a tab with a real shell behind it, which is about the most consequential thing you can put behind a URL.
## One desk signs in every visitor
Recent versions of the identity provider dropped their embedded proxy, so the outpost is now a deployment you run yourself. That sounds like a tax. It's the opposite, because you only ever run one. A single outpost serves many providers: each new application binds a new provider to the *same* instance. When the browser IDE arrived - the second tenant - the marginal cost was a provider object, a route pointing its hostname at the outpost, and a matching pair of network policies. No new deployment. App N+1 is a nameplate at the desk, not a second desk.
Standing up the desk the first time is where the hours went. Three traps, each with a price paid in real time:
- **A provider created by automation isn't a provider created by the UI.** Scripting the provider into existence skipped the OAuth defaults the admin UI sets silently - the redirect URIs sat empty, and every successful login dumped the user onto the identity provider's own homepage with a shrug of "no provider url". The login worked; it just went nowhere. Set the defaults explicitly, every time.
- **The outpost has to be told which host the browser sees.** Left alone it knows only its internal service name, and it will happily redirect your browser to an address that exists nowhere outside the cluster. One environment variable carrying the browser-facing hostname closes the loop, and it wins over anything set through the API.
- **A token with an invisible trailing newline fails like everything else fails.** The outpost's token, pasted into a secret, carried a newline you cannot see. The authorisation header it produces is invalid, and the symptoms are indistinguishable from both traps above. Strip the newline before the secret ever exists.
## Couriers don't queue at reception
The registry is the sharper case, because its real customers were never people. Every node's container runtime pulls images through its API - the `/v2` path - dozens of times a day. Put the doorman in front of that and every pull in the fleet dies waiting on a login page no runtime can answer.
So the route splits at the gateway. `/v2` goes straight to the registry. Everything else on the same hostname - the UI, the search - goes to the outpost and gets the full sign-in. And the direct path isn't left swinging open as the price of working: it's keyed with its own scoped, read-only identity, carried in each node's registry configuration. The couriers don't queue at reception, and reception doesn't prop the dock open for them either.
One hostname, two audiences, two doors, and neither is weakened to accommodate the other.
## Rattle every door before you trust the lock
The proof discipline matters as much as the pattern. The easy check is to load the homepage, watch it bounce to the sign-in page, and call the thing secured. But the homepage is the door nobody breaks in through.
The browser IDE made this concrete. Its whole value is a terminal speaking over a WebSocket, and a WebSocket doesn't render a login page. It either connects or it doesn't. Had the route carved that path out for convenience, the front door would be locked while a side corridor ran straight past the desk, exactly where no identity is ever checked. The same goes for static assets, and for the health endpoint.
So the route sends every path on the host to the outpost, and the check was empirical: an unauthenticated request to the root, to the terminal WebSocket, to a static asset and to the health endpoint each came back as a redirect to the identity provider. All of them. On a human door, a single carve-out *is* the hole; carve-outs belong only on machine doors, where they get keys of their own. And behind the doorman, the IDE still keeps a lock of its own - the desk adds a layer; it doesn't replace the one on the office door.
## Slice auth by who arrives
Slice authentication by who arrives, not by what serves the request. Humans are one audience - interactive, browser-borne, phishable, and they get a doorman: one strong flow, every human door in the estate behind the same desk, so door N+1 inherits the whole policy for the cost of a nameplate. Machines are the other audience - headless, scoped, incapable of answering a challenge, and they get narrow keys to their own doors, which the doorman never learns about.
The one-layer instinct fails because it treats the app as the unit of security. The unit is the audience. Sign the people in at one desk, key the machines at the dock, and neither door ever has to weaken to let the other's traffic through.
*Live in the homelab: one Authentik proxy outpost on the cave cluster fronting the Zot registry UI and a code-server IDE; the registry's `/v2` pull path keyed separately with a read-only identity across six Talos nodes.*
@@ -0,0 +1,55 @@
---
title: "Consistency by construction beats consistency by discipline"
date: 2026-08-17
summary: "One address had to appear in the application config, the network attachment and two policy objects - four artefacts, four update paths. Rendering every one of them from a single required value made divergence something the tooling can no longer express."
tags: ["automation", "helm", "iac", "openshift", "fleet"]
draft: false
hero: "/blog/one-value-many-enforcers.webp"
heroAlt: "A single source node feeding four identical threads of light into four separate gates"
---
Every cluster in a small fleet runs a workload that talks to one device across a second network
interface, and that device's address has to appear in four artefacts. The application config, so the
workload knows what to call. The network attachment, so it gets the right address on the right
interface. The egress rule, so the traffic is permitted. And the policy governing the second
interface, for the same reason.
Four places. Four chances for someone to update three of them.
<!-- DIAGRAM: one cluster value feeding the application config, the network attachment, the primary policy and the secondary policy - one source, four renders -->
![Diagram of one cluster value feeding four rendered artefacts - the application config, the network attachment and both policies - with the required-field check failing the render when the value is absent.](/diagrams/one-value-many-enforcers.svg)
## The failure mode has no error message
That is not hypothetical - a version of it had already happened here. An address changed in one
artefact and not another, and the mismatch produced no error. It produced a policy that permitted
traffic to somewhere nothing lived, while the actual traffic went somewhere unpoliced. Everything
reported healthy. The enforcement had simply stopped meaning anything.
Config that must agree across several artefacts will eventually disagree, because the update path
requires a human to remember all of them at once, under time pressure, months after writing them.
## Template from one source
The change that held: the address lives once, in the cluster's values, and every artefact that
needs it renders from there. The application config, the attachment definition, both policies - all
templated from the same field.
Now they cannot disagree. Not "are unlikely to" - *cannot*, because there is nothing to update
twice. Changing the address is one edit, and every enforcement point follows automatically because
they were never independent copies to begin with.
## Make absence fail loudly
The other half is refusing to render without it. The schema marks the field required, so a cluster
that has not supplied an address fails at template time with a message that names the missing field.
That failure is a gift: it lands in the pipeline, before anything ships, instead of surfacing months
later as enforcement pointed at nothing.
## The render that refuses
Discipline was the old control here, and it had already failed once. Construction does not tire and
cannot half-finish an edit: every consumer renders from the one field, or nothing renders at all.
The failure you want is the render that refuses, not the deployment that polices the wrong address.
*Since fixed: the address field is required in the schema, and a render without it fails the pipeline.*
+4 -4
View File
@@ -15,14 +15,14 @@ that's online whether or not anyone's knocking.
There's a better shape: don't open anything. Let the origin dial *out*.
![Outbound-only exposure public traffic hits a Cloudflare edge with WAF and geo rules; the origin holds a single outbound tunnel; nothing is port-forwarded](/diagrams/outbound-exposure.svg?v=2)
![Outbound-only exposure - public traffic hits a Cloudflare edge with WAF and geo rules; the origin holds a single outbound tunnel; nothing is port-forwarded](/diagrams/outbound-exposure.svg?v=2)
## The tunnel dials out
A lightweight connector runs next to the service and opens a persistent **outbound** connection to the edge
network. Public traffic arrives at the edge, and the edge hands it back down that already-open tunnel. The
router has no inbound rule. The origin's public IP is never advertised. Port-scan the home connection and
there's nothing listening because there isn't.
there's nothing listening, because there isn't.
You've inverted the trust direction: instead of the internet reaching *in*, the box reaches *out*.
@@ -30,7 +30,7 @@ You've inverted the trust direction: instead of the internet reaching *in*, the
The wrinkle is DNS. I run **split-horizon**: the internal domain resolves to in-cluster ingress for anyone
on the LAN, and the public domain resolves through the edge for the outside world. Same services, two names,
two answers depending on where you're standing and the internal estate is never reachable through the
two answers depending on where you're standing, and the internal estate is never reachable through the
public path.
This bites in a non-obvious way too: a pod *inside* the cluster can't resolve the public hostname (it gets
@@ -49,7 +49,7 @@ So the controls live there, not on the origin:
The origin's job shrinks to one thing: hold the tunnel open and serve. Everything hostile is filtered a
continent away.
## The principle
## The safest port is the one never listening
Exposure isn't binary, and it isn't a synonym for port-forwarding. Push the perimeter out to an edge you
don't host, make the origin speak only outbound, and the attack surface at home collapses to zero open
+58
View File
@@ -0,0 +1,58 @@
---
title: "A new model is a new hire, not a new file"
date: 2026-08-17
summary: "A model pulled from the hub is not just weights - it ships tokeniser code, an executable chat template, loaders gated by trust_remote_code, sometimes its own runtime, and the ecosystem's default is to run all of it beside your credentials. So every new model starts on probation: a hardware-isolated microVM with its own guest kernel, a default-deny network, zero credentials, and promotion to the standard serving tier only when the observed record earns it."
tags: ["ai", "security", "kata", "isolation", "homelab"]
draft: false
hero: "/blog/probation-for-models.webp"
heroAlt: "A new arrival's first supervised shift: an android-like figure of light works alone inside a glass observation room on a vast dark facility floor, instruments watching from outside, the busy permanent floor glowing far beyond"
---
Pull a model from the hub and notice what your own head does with it. A directory of large binary files arrives, so it gets filed under *data*. Weights. Tensors. Numbers, and numbers can't run.
Except that isn't what you downloaded. A model artefact ships tokeniser code. It ships a chat template - an executable template the runtime evaluates on every request. It often ships a loader gated by a flag whose name confesses everything, `trust_remote_code=True`, and sometimes it effectively ships its own runtime, pulled as a container image from someone else's registry. The ecosystem's default is to run all of this with cluster-level convenience: the same kernel as everything else you host, the same namespace, a service-account token mounted at the usual path. Treat a fresh model as data and you will, sooner or later, execute a stranger's Python next to your credentials.
I've argued before that untrusted code belongs in a VM, not a namespace; the downloaded model is that argument's most routinely ignored case. So the operating rule in this fleet is a hiring rule, not a file-handling one: **new models start on probation**. You didn't add an asset to storage. You hired a stranger off the internet, and a new hire doesn't get keys to anything on day one.
<!-- DIAGRAM: the probation ladder, left to right. ARRIVE: a model artefact (weights + tokeniser code + chat template + loader), pinned by digest with its runtime. PROBATION TIER (live machinery): Sandbox CR -> pod with runtimeClassName kata -> per-pod KVM microVM with its OWN guest kernel, inside a zero-peer default-deny namespace; zero credentials mounted; egress opened only to declared needs; broker tier above holds all real keys. OBSERVE: real workloads through the harness; the record = egress log + behaviour vs declaration. GATE: record matches declaration over time -> PROMOTE to the standard serving tier behind the gateway; unproven -> stays in probation indefinitely (cheap). Highlight the hardware boundary between the container and the node kernel. -->
![The probation ladder: arrive pinned, run supervised in a microVM behind default-deny with zero credentials, build a record, promote only when the record matches the declaration](/diagrams/probation-for-models.svg)
## Admit what you actually downloaded
Consider what you'd normally know about a new starter: references you can call, a work history someone vouches for, an interview where you watched them think. Now inventory what you know about a model pulled from a public hub: an account name, a download count, a README written by the account. That's the whole CV, and none of it is checkable.
Meanwhile the artefact's executable surface is wider than most people's mental picture of it. The older checkpoint formats are pickles, and unpickling is code execution - safetensors exists precisely because "loading the weights" used to mean "running a program". Custom architectures ask for `trust_remote_code`, which imports and runs whatever Python the repository carries, in your process, with your permissions. Even the innocuous chat template is a small program, executed on every request. Little of this is malice; nearly all of it is engineering convenience. But convenience is how a stranger's code ends up running beside a token that can list every secret in the namespace.
You cannot call this candidate's references. The only reference check available is watching it work, which is what a probation period is.
## Give the stranger a supervised shift
The probation tier here is not a policy document; it's a namespace, and everything in it is live and verified. Kata Containers is baked into the node's install image, and a pod that sets `runtimeClassName: kata` doesn't get a slice of the host kernel - it boots inside its own KVM microVM, with its own guest kernel. Not a claim from the docs: run `uname -r` inside such a pod and it reads 6.18.28 while the node underneath runs 6.18.34. A workload that escapes its container has escaped into a guest VM, with a hardware boundary still between it and the node.
A `Sandbox` resource (kubernetes-sigs/agent-sandbox) wraps that pod and owns its lifecycle, and the namespace floor does the supervising. The network is default-deny with zero peers - a fresh sandbox can pull its image and reach nothing else until someone writes down, explicitly, the handful of addresses it's allowed. Admission rejects any pod that tries to start in that namespace without the microVM runtime, so nobody can absent-mindedly schedule a shortcut. And the tier holds no credentials at all, by design: anything a workload legitimately needs is asked for through the broker layer above it, which holds the real keys - the pattern from [an agent should never hold the key it's using](/blog/broker-pattern).
That's a supervised shift in full. Escorted everywhere it goes, nothing in its pockets, every outside contact through a chaperone, and the building stays standing even if the new starter turns out to be hostile.
## Promote on the record, not the calendar
Here is the honest line between proven and doctrine. The machinery above is live: the microVM boots, the guest kernel differs, the deny floor holds - all verified. The ladder is the operating rule that machinery was built to serve, and it goes like this.
A candidate model runs its evaluation harness inside the microVM, pinned by digest - the exact artefact and the exact runtime, not a tag that can drift under you. Its declared needs are written down first: these endpoints, this storage, nothing else. Then the record accumulates over real workloads. Egress matches the declaration, or it doesn't. Behaviour matches expectation, or it doesn't - no surprise network, no surprise syscalls, no creative interpretation of its job. When the record is long enough and boring enough, probation ends: the model is made permanent and moves to the standard serving tier behind the gateway, where the fast path lives.
The quiet half of the rule is the better half: anything unproven simply stays in the VM. Forever, if need be. Permanent probation costs almost nothing - the model still works, still answers, still earns its keep on supervised shifts. Promotion is a decision you can decline to make indefinitely, and the candidate has no grounds to complain.
## Pay the probation tax without flinching
None of this is free. A microVM boots in seconds, not milliseconds. I/O crosses a virtualisation boundary and pays for the trip. Every pod carries its own guest kernel in memory - this fleet books 350 MiB of overhead against each microVM so the scheduler doesn't lie to itself about what fits. Probation-tier work is batch-shaped, not interactive.
That's fine, because it's what probation means. You don't measure a supervised shift by throughput; you measure it by what it reveals. The standard tier - shared kernel, warm caches, low latency - exists because the promotion is earned, and a reward only means something if the default is slower. The tax isn't a flaw in the ladder. The tax *is* the ladder.
## Trust is a history, not a property
Trust is not a property of software. No scanner emits it, no licence contains it, and a clean hash only proves that today's stranger is the same stranger as yesterday - it says nothing about what the stranger does. Trust is a history: observed behaviour, under constraint, accumulated until it's boring. And it's graduated, never granted on arrival.
The reason to bother is worth saying plainly. A model is not a config value you swap on a hunch; it is the part of the system that decides what is true, and it arrives with a wider executable surface than almost anyone pictures. Weights, agents, plugins, anything that turns up executable - the ladder is the same. Start it where an escape lands in a guest kernel, where the network answers to a written declaration, where there are no credentials to spend. Let the record grow. Promote on the record, or not at all.
Give every new arrival a place to build a history where the worst it can do is bounded.
*Live on the fleet's untrusted tier: Kata 3.31.0 baked into a Talos node image, RuntimeClass `kata` booting per-pod KVM microVMs (guest kernel 6.18.28 on a 6.18.34 host), agent-sandbox v0.4.6 wrapping them in Sandbox resources, a zero-peer default-deny namespace holding not one credential - the supervised shift the next downloaded model walks into.*
+65
View File
@@ -0,0 +1,65 @@
---
title: "A backup you haven't restored is a rumour"
date: 2026-07-03
summary: "Backup jobs report success every night for years, and none of those green ticks proves the one thing backups exist for. The only receipt is a restore drill: files pulled from the repository, checked, and running. Here's the discipline, and the failure it caught."
tags: ["backups", "disaster-recovery", "kubernetes", "restic", "homelab"]
hero: "/blog/restore-or-rumour.webp?v=1"
heroAlt: "A dim vault with a small tray of retrieved data-crystals under an examination lamp - the proof is what was brought back OUT, not what sits sealed inside."
draft: false
---
Ask an estate whether it has backups and you'll get a confident yes: the job runs nightly, the dashboard is
green, the repository is growing. Ask when anyone last *restored* one and the room goes quiet.
Here's the uncomfortable framing: an unverified backup is not evidence - it's a rumour. Someone once said
the data was safe, the claim got repeated nightly by a cron job, and everyone chose to believe it. The green
tick proves a process exited zero. It says nothing about whether the bytes at rest can be turned back into a
running application on the worst day of the year.
## The restore drill is the acceptance test
When I consolidated the homelab's backups onto the NAS - every cluster's application state streaming into
one repository per cluster - the job wasn't "done" when the first snapshot landed. It was done when a
scratch pod pulled thousands of files back *out* of the repository, the configs checked intact, and the
services proved recoverable from nothing but the backup and its key. Both clusters. Before the old backup
path was retired, not after.
That's the rule worth writing down: **a backup pipeline ships with its restore drill, the same way code
ships with its tests.** Not a runbook that describes a restore - an actual rehearsal, on the real
repository, with a pass/fail outcome.
<!-- DIAGRAM: nightly snapshot arrows into a repository; a drill loop pulling a snapshot back OUT into a scratch pod with a green check - the loop is what's highlighted, not the vault -->
![The drill loop: data out of the vault is the proof, not data in](/diagrams/restore-or-rumour.svg?v=1)
## What the drill actually catches
The failures a drill surfaces are precisely the ones the nightly tick can't see. A repository still locked
by a dead process from last week's crashed run - every subsequent "successful" backup queued behind a stale
lock. A prune step that had been silently failing. And the one that matters most on the worst day: whether
the *key* is where the recovery plan says it is. Every one of those was invisible from the green-tick view,
and every one is a nothing-burger to fix on a Tuesday afternoon, and an incident report if discovered
during a real recovery.
There's a quieter design lesson the drill enforces too: back up **state, not just volumes**. A database dump
you can restore beats a filesystem snapshot you have to forensically reassemble; the drill makes you notice
the difference, because the drill is where you actually try.
## One key to rule the recovery
Consolidation concentrates risk on purpose - one NAS, one repository format, one passphrase unlocking every
cluster's history. That's a fine trade *if* the key management is treated with the same rigour as the data:
the passphrase lives offline, not in any repository it unlocks, and the recovery plan starts from "a bare
machine and the key", assuming nothing else survived. A backup encrypted with a key stored next to it is a
padlock with the key taped to the shackle.
## Trust receipts, not reports
Trust receipts, not reports. Any system whose entire purpose is a future emergency - backups, break-glass
accounts, failovers, restore paths - is untested by definition on every normal day, so you have to
manufacture the test: drill it, on a schedule, with a real pass/fail, and treat a failed drill as a real
incident. The nightly green tick is the rumour. The drill is the receipt.
*Live across the homelab: per-cluster restic repositories on the NAS over SMB, nightly CronJobs, restore
drills run before each cutover - thousands of files pulled back and verified per cluster, a stale-lock
failure caught and self-healing added, the passphrase held offline.*
+14 -14
View File
@@ -1,7 +1,7 @@
---
title: "The first secret is the one you can't commit"
date: 2026-06-28
summary: "Secrets management has a bootstrap paradox: the credential that pulls every other secret can't itself live in git. Here's how a homelab fleet breaks the cycle zero plaintext secrets in any repo, and a clean rule for which is the one exception."
summary: "Secrets management has a bootstrap paradox: the credential that pulls every other secret can't itself live in git. Here's how a homelab fleet breaks the cycle - zero plaintext secrets in any repo, and a clean rule for which is the one exception."
tags: ["security", "gitops", "secrets", "kubernetes", "external-secrets"]
draft: false
hero: "/blog/secret-zero.webp"
@@ -13,12 +13,12 @@ Every "we do GitOps properly" story has a chicken-and-egg problem hiding in the
posts quietly skip it.
The pitch is clean: secrets never live in git. Instead, the cluster runs the [External Secrets
Operator](https://external-secrets.io) (ESO), which reads from a real secret store here, a self-hosted
[Infisical](https://infisical.com) and materialises a Kubernetes `Secret` for each app. Your repo only ever
Operator](https://external-secrets.io) (ESO), which reads from a real secret store - here, a self-hosted
[Infisical](https://infisical.com), and materialises a Kubernetes `Secret` for each app. Your repo only ever
contains an `ExternalSecret` manifest: a **pointer** ("give `immich` the value at key `IMMICH_DB_PASSWORD`"),
never a value. Beautiful. Auditable. Diff-able.
Except ESO has to authenticate to Infisical somehow. That's a credential. Where does *it* live?
Except - ESO has to authenticate to Infisical somehow. That's a credential. Where does *it* live?
## Secret zero
@@ -26,24 +26,24 @@ You can't store the bootstrap credential in git (that's the whole point), and yo
secret store (you need it *to reach* the secret store). This is **secret zero**: the one credential that the
entire chain hangs off, that has to be injected from outside the GitOps loop.
So you treat it as exactly that special, minimal, and out-of-band:
So you treat it as exactly that - special, minimal, and out-of-band:
- It's a **machine identity** scoped to *read-only*, and scoped per cluster. cave's ESO can't read alfred's
secrets and vice-versa. A leak is blast-radius-limited to one cluster's read path.
- It's applied **once, by hand** (or by a sealed bootstrap step), never committed. Everything downstream of it
is declarative.
- After it lands, a single `ClusterSecretStore` object which *is* in git, because it's just a pointer to the
store plus a reference to secret zero wires the whole cluster up.
- After it lands, a single `ClusterSecretStore` object, which *is* in git, because it's just a pointer to the
store plus a reference to secret zero - wires the whole cluster up.
From there the dam breaks in the good way: every app's `ExternalSecret` resolves through that store, ESO keeps
the `Secret` in sync, and your repo stays a map of *names*, not values.
<!-- DIAGRAM: secret-zero bootstrap chain out-of-band identity ClusterSecretStore ExternalSecrets app Secrets -->
<!-- DIAGRAM: secret-zero bootstrap chain - out-of-band identity -> ClusterSecretStore -> ExternalSecrets -> app Secrets -->
![Secret-zero bootstrap chain](/diagrams/secret-zero.svg?v=1)
## The discipline around the one exception
One out-of-band credential is fine. The trap is letting it sprawl or fumbling its rotation. Two rules earn
One out-of-band credential is fine. The trap is letting it sprawl, or fumbling its rotation. Two rules earn
their keep:
**Per-cluster, least-privilege identities.** It's tempting to mint one powerful identity and reuse it
@@ -53,22 +53,22 @@ every node.
**Never revoke a shared identity until every consumer has moved off it.** This sounds obvious and is the
single easiest way to take the fleet down. When you split a shared identity into per-cluster ones, the old one
stays valid until you've *verified* each cluster is happily authenticating on its own then, and only then,
stays valid until you've *verified* each cluster is happily authenticating on its own - then, and only then,
you revoke. Revoke-first-verify-later turns a tidy-up into an outage.
## What still belongs in git (encrypted)
A purist would stop here, but reality has a few things that genuinely need to live *in* the repo a value a
A purist would stop here, but reality has a few things that genuinely need to live *in* the repo: a value a
bootstrap step reads before ESO is even running. For those, the answer isn't "commit it in plaintext and feel
bad," it's **SOPS + age**: the value is encrypted in git, decryptable only by a key that lives on the
operators' machines (and the cluster), never in the repo. Same principle as secret zero the *decryption* key
is the out-of-band thing applied to the handful of values that can't wait for the operator to spin up.
operators' machines (and the cluster), never in the repo. Same principle as secret zero (the *decryption* key
is the out-of-band thing) applied to the handful of values that can't wait for the operator to spin up.
## The shape of it
The win isn't any one tool. It's the shape: **exactly one** credential lives outside GitOps, it's read-only
and per-cluster, and it's the seed the whole tree grows from. Everything else is a pointer you can show
your worst enemy. When someone asks "where are your secrets?", the honest answer is "in the store the repo
your worst enemy. When someone asks "where are your secrets?", the honest answer is "in the store - the repo
just knows their *names*," and that one sentence is the whole security model.
*This runs across a five-cluster Talos homelab; the ESO + Infisical wiring, the per-cluster read-only
+29 -20
View File
@@ -1,43 +1,52 @@
---
title: "Shipping this site: GitOps from a homelab to the public internet"
date: 2026-06-15
summary: "How this portfolio is built and served Astro to a container image, a self-hosted Gitea registry, ArgoCD, and a Cloudflare Tunnel with security as acceptance criteria, not polish."
summary: "How this portfolio is built and served - Astro to a container image, a self-hosted Gitea registry, Argo CD, and a Cloudflare Tunnel - with security as acceptance criteria, not polish."
tags: ["gitops", "astro", "homelab", "security"]
hero: "/blog/shipping-this-site.webp"
heroAlt: "A glowing data container travels a luminous rail from a small server rack toward a softly glowing globe of Earth, wrapped by a reconcile loop."
---
This site is a static Astro build, but how it gets to you is the interesting part. It's
served from my homelab Kubernetes cluster over a Cloudflare Tunnel, deployed the same way I'd
ship anything else: as an immutable image, pinned by digest, reconciled by GitOps.
This site is a static Astro build, and that's the least interesting thing about it. What matters
is how it reaches you: served from my homelab Kubernetes cluster over a Cloudflare Tunnel, shipped
the way I'd ship anything I actually cared about - an immutable image, pinned by digest, reconciled
by GitOps. No special case for "it's just a website."
## The pipeline
1. The site is built and baked into a hardened `nginx-unprivileged` image.
2. The image is pushed to a **self-hosted public Gitea registry** deliberately separate
from the private instance that holds my infrastructure code.
3. The image digest is pinned in a private `home-ops` repo.
4. **ArgoCD** reconciles that repo onto the cluster.
5. A **Cloudflare Tunnel** exposes exactly one service — this site — outbound-only.
The build is baked into a hardened `nginx-unprivileged` image and pushed to a **self-hosted public
Gitea registry**, deliberately a separate instance from the private one holding my infrastructure
code, so the public artifact and the private estate never share a trust boundary. The image digest
is then pinned in a private `home-ops` repo, **Argo CD** reconciles that repo onto the cluster, and
a **Cloudflare Tunnel** exposes exactly one service, this site, outbound-only.
No open ports. No server runtime. No registry credential on the cluster, because the public
package is anonymous-pull and the image holds nothing secret.
Follow the chain and notice what's missing. No open ports: the tunnel dials out. No server runtime:
the output is static files behind nginx. No registry credential on the cluster: the public package
is anonymous-pull, and the image holds nothing secret to protect. Every link in the pipeline is
either immutable, declarative, or absent.
## Security as acceptance criteria
The interesting constraint was treating security as a checklist to *pass*, not a vibe:
The discipline that made it work was treating security as a checklist to *pass*, not a vibe to
gesture at. The site didn't ship until every box was ticked:
```text
[x] Static output no server runtime to attack
[x] Strict CSP, no unsafe-inline / unsafe-eval
[x] Self-hosted fonts zero third-party requests
[x] Static output - no server runtime to attack
[x] CSP: script-src 'self', no inline or eval JavaScript (style-src keeps 'unsafe-inline' for Shiki)
[x] Self-hosted fonts - zero third-party requests
[x] No secrets in the client bundle (verified by build-time grep)
[x] Outbound-only tunnel, single hostname, no catch-all
```
## Why bother
A checklist sounds bureaucratic until you notice what it changes: each item is a claim you can
verify, and a failing item blocks the ship. "Pretty secure" isn't a state you can test for.
`grep` finding zero secrets in the bundle is.
Because the site *is* the argument. A platform engineer's portfolio should demonstrate the
discipline it's advertising — and "it's a static page" is no excuse to skip the rigour. The
deployment story is part of the work.
## The site is the argument
The site *is* the argument. A platform engineer's portfolio should demonstrate the discipline it
advertises, and "it's a static page" is no excuse to skip the rigour - it's the cheapest possible
place to practise it. If the pipeline behind a brochure site is immutable, verified, and
zero-trust, that's not overkill. That's the standard, rehearsed where the stakes are low so it
holds where they aren't.
+48
View File
@@ -0,0 +1,48 @@
---
title: "Six days of nothing, and nothing noticed"
date: 2026-08-17
summary: "A VPN tunnel died and downloads stopped for six days. Every container stayed running, every check passed, and the platform was entirely satisfied, because nothing was watching the thing that had actually failed."
tags: ["observability", "monitoring", "reliability", "alerting", "homelab"]
draft: false
hero: "/blog/six-days-of-silence.webp"
heroAlt: "A wall of uniformly lit all-clear indicator lamps above a floor pipeline that has visibly run dry"
---
It was found by accident. Someone asked how the downloads were going, and the answer was that they
had not gone anywhere in six days. The tunnel that carries them had failed and the process
responsible had kept running - alive, responsive, and completely useless.
Nothing alerted, because nothing was watching the tunnel. The container was up. The platform had no
reason to think otherwise.
<!-- DIAGRAM: a container running with a healthy process while its tunnel is down, no probe covering the tunnel, and the six-day gap between failure and discovery -->
![Diagram of a running container with a healthy process beside its failed tunnel, with no probe covering the tunnel and a six-day gap before discovery](/diagrams/six-days-of-silence.svg)
## Running was never the job
The gap between "the process is running" and "the process is doing its job" is where invisible
outages live. A tunnel client with a dead tunnel is still a healthy process. It answers, it holds
its port, it logs. It simply is not carrying anything, and no default check in the stack has an
opinion about that.
Worse, the failure was self-obscuring. Its internal retry backoff kept growing, so the logs got
quieter over time rather than louder. By day six, the system was producing almost no evidence that
anything was wrong.
## Health lives in the flow
What closed the gap was probing the capability rather than the container: the tunnel's own health
check, which only passes when traffic genuinely egresses through it. That converts a silent failure
into a restart and, eventually, an alert - a thing the platform can see and act on.
The general rule: for anything whose value is a *flow*, health means the flow works. Ask whether
data recently moved, not whether the mover is alive.
## The question that found it
Health checks should assert the outcome the component exists to produce. A running process proves
almost nothing about a system whose job is to move something, and a failure nobody is watching for
will always be discovered the way this one was - by a person, on the day they happen to ask.
*Live in the lab: the tunnel's health check gates the container now - nothing reports healthy
unless traffic egresses through it.*
+95
View File
@@ -0,0 +1,95 @@
---
title: "Powered off is a state, not an incident"
date: 2026-08-02
summary: "Three of this fleet's six machines were upgraded, verified healthy, and then deliberately shut down the same night, and the books record a state, not a failure. When a node's entire existence is a rebuildable document, powered off becomes something you schedule, not something you fear."
tags: ["homelab", "operations", "talos", "sustainability", "architecture"]
draft: false
hero: "/blog/sleep-half-the-fleet.webp"
heroAlt: "A dark tower of circuit-traced architecture at night - most windows unlit and resting, a few glowing where the core still works"
---
Homelabs run 24/7 out of habit. The habit comes with a scoreboard - `uptime` counters climbing into the
hundreds of days, worn as proof of seriousness, and with two feelings nobody says out loud: the guilt of
turning a server off, as if a dark machine means you weren't serious; and the quieter fear that if you do
turn it off, it won't come back. Somewhere on that box, the fear whispers, is a state nobody wrote down,
held together by warmth.
Meanwhile the actual work is bursty. GPU inference runs when a request arrives. Transcode runs in batches
and finishes. The hardware idles around the clock for workloads that occupy it a fraction of the time - real watts, real fan-hours, real heat, spent keeping silicon warm for nobody.
Last night I shut down half the fleet on purpose. Three machines - two of them carrying an RTX 5080 each - were upgraded in the afternoon, verified healthy in the evening, and powered off before midnight. Nothing
broke, nothing was lost, and the fleet's books record the event as a state, not a failure. Think of a
building at night: the porter walks the floors, the lights go off in the wings nobody's using, and the
building isn't failing. It's resting, and the porter writes it in the log.
<!-- DIAGRAM: three node power states in a row - READY (lit) <-> OFF-BY-CHOICE (dark, a single power-button arrow back to Ready) and, set apart, PARKED-BROKEN (dark, a screwdriver arrow as its only exit); beneath all three states one continuous, unbroken document layer (git: machine config / CNI inline / workloads / credentials) - the document layer is what's highlighted, the power states sit on top of it as incidental -->
![One document persists beneath every power state - Ready, off by choice, parked broken](/diagrams/sleep-half-the-fleet.svg)
## Make the node a document, and power becomes a detail
None of this is safe by default. It's safe because of a property I argued for in [the sibling
post](/blog/no-ssh): each of these machines is a single-node Talos cluster whose entire existence is
declared. The machine config pins the disks by serial and the network interfaces by MAC; the CNI ships
inside that same document as an inline manifest; the workloads arrive from git the moment the node reports
Ready, reconciled by the fleet's GitOps controller; the credentials are substituted in at render time from
an encrypted file. There is no state that exists only on the box. The node *is* a document - power is just
whether it's currently being read.
That property is what made this week's sequence unremarkable. Three nodes had their network datapath
swapped - kube-proxy out, Cilium's eBPF replacement in, and their registry credentials rotated, one reboot
each. Every check came back green: GitOps fully synced, secrets flowing, every endpoint answering. And
then, the same night, all three were shut down. Upgraded, then slept. Nothing about the upgrade is lost to
the power state: it's on disk, in the config, in git. When the power button is next pressed, each node
boots into exactly the machine its documents describe - including everything that changed yesterday.
How they went down matters as much as the fact that they could. `talosctl shutdown` is a clean, API-driven
stop: workloads terminated properly, etcd closed cleanly, the platform powering itself off - confirmed dark
before anyone walked away. A graceful shutdown is what makes the next boot boring. Pull the plug instead
and you've converted a healthy state into a small forensic exercise for future-you.
## Keep the two kinds of dark apart
Four of the six machines are dark tonight, and the fleet's records refuse to treat them as one category.
Three (robin, kate and wgirl) are **off by choice**: healthy at shutdown, upgraded that same day, one
press of a power button from Ready. The fourth, elfastc, is **parked broken**: dark because of a hardware
fault, waiting on a screwdriver, not a switch. From the network's point of view the four are identical: no
link light, no metrics, nothing to distinguish rest from wreckage. From the operator's they are different
states with different exits, and the roster records which is which, in the same versioned pages as
everything else.
This is the porter's log. A dark wing and a broken lift look the same from the street; the difference is
written down at the desk. Six months from now, "why is this node off?" must have an answer that isn't
archaeology, because a fleet that can't tell *resting* from *broken* will eventually treat one as the
other, and both mistakes are expensive: rebuilding a healthy node because nobody trusted it, or trusting a
broken one because it merely looked asleep.
## Pay the costs where you can see them
Sleep isn't free, and pretending otherwise is how the 24/7 habit wins the argument. Two costs are real, and
both are stated in the books.
First: what lives on a sleeping node is dormant, not migrated. The fleet's LLM backend lives on wgirl's
GPU, and it sleeps when she does. The assistant app that depends on it stays up - it runs on a node that's
awake, but until power-on it has no model to call. That is a genuine loss of capability, accepted
deliberately, recorded next to the state that caused it. If the trade ever stops being worth it, the fix is
a power button, not a redesign.
Second: monitoring fires as a node goes silent. The metrics stream stops mid-sentence and the alert raises,
exactly as it should - the monitoring has no way to know the silence was chosen, and it must never assume
so. The wrong response is to call that a false alarm. It isn't. It's the system being honest about a
deliberate act, and the deliberate act is written down where the alert can be checked against it. The
porter doesn't disable the alarm panel for the dark wings - he checks the panel against the log.
## Design for off
Design for off. A node you can power down without ceremony is a node whose whole truth lives outside it - in documents, in git, in the log, and that is precisely the property you need on the day a node goes down
without asking. The fleet you can put to sleep on purpose and the fleet that shrugs off losing a node in
anger are the same fleet; the two abilities are one property. Uptime measures how long a machine has been
running. It says nothing about how well you hold it. A building isn't failing when its lights go out at
night - it's failing when nobody dares touch the switch.
*Live in the fleet's books tonight: three of six Talos nodes - robin and wgirl (an RTX 5080 each) and kate
(the AI sandbox) - upgraded to the new datapath, verified green, then gracefully shut down through the API
and recorded off-by-choice; elfastc parked-broken awaiting a screwdriver; cave and alfred carrying the
always-on core.*
+95
View File
@@ -0,0 +1,95 @@
---
title: "SNAT ate my source IP"
date: 2026-07-14
summary: "A LoadBalancer service with the default traffic policy rewrites every incoming packet's source to the node's own address, so by the time a network policy sees it, the real client is gone. You cannot allowlist a sender the network has already erased."
tags: ["security", "networking", "cilium", "kubernetes", "load-balancing"]
draft: false
hero: "/blog/snat-ate-my-source.webp"
heroAlt: "A glowing envelope passing through a dark sorting machine that stamps over its return address"
---
Here's the uncomfortable default: give a Kubernetes service a LoadBalancer IP, leave
`externalTrafficPolicy` at its default of `Cluster`, and **every packet that arrives gets its source
address rewritten to the node's own**. Not by an attacker. By the load balancer, on purpose, as
routine plumbing.
I found out the way you always find out - by writing a network policy for it. LAN clients live on
`10.0.11.0/24`, so I wrote a `fromCIDR 10.0.11.0/24` allow. Applied cleanly, showed `Valid=True`,
dropped every real user. Hubble told me why: the traffic wasn't arriving from the LAN at all. Cilium
tagged the source `world` - the identity of *anywhere*, the one you can't restrict without
restricting everything.
It's a letter that reaches you carrying the sorting office's return address instead of the sender's.
The sorting office isn't lying to you; it's how the machinery forwards mail. But you can't write a
"letters from Alice only" rule when every envelope on your doormat says it came from the depot.
<!-- DIAGRAM: client -> LB VIP -> SNAT at the node (source rewritten) -> policy sees `world`, vs ETP:Local preserving the client address -->
![SNAT rewriting the client source before policy evaluation, and ETP:Local preserving it](/diagrams/snat-ate-my-source.svg)
## Why the depot stamps over the sender
The rewrite has a reason. With `externalTrafficPolicy: Cluster`, any node can accept traffic for the
service and forward it to a backend pod on a *different* node. For the reply to route back through
the node that forwarded it, that node SNATs the connection to itself. Balanced spreading, bought by
destroying the source.
The consequence lands exactly where you can't see it coming: **policies bind to what arrives, not to
what was sent.** The client's packet left home with an honest return address; the depot stamped over
it in transit; your policy - evaluated after the rewrite - never meets the client at all. There is no
rule you can write for an identity the network erased one hop earlier.
## Preserve the sender, then commit it
The fix is one line: `externalTrafficPolicy: Local`. Only nodes running a backend pod accept
the traffic, no cross-node forwarding happens, no SNAT is needed, and the client's real address survives
to policy evaluation. Now `fromCIDR 10.0.11.0/24` matches LAN clients, and a cross-cluster peer shows
up as its node IP (the peer *node*, not the peer pod, because separate clusters masquerade pod
traffic on the way out), pin-downable with a `/32`. That's how the LLM gateway's ingress rule got to
be a single line: one legitimate consumer, the agent cluster next door, exactly one `/32` allowed in.
Here's the one that costs a 2am. I made that change with `kubectl patch` - quick, out-of-band, worked
immediately. The service was GitOps-managed with self-heal on. Within seconds Argo CD noticed the live
object differed from git and put it back the way the repo said, which re-enabled the SNAT, which
re-broke the policy, which took DNS down with it. **The change must be committed**, or the platform
will politely undo your fix while you sleep.
One caveat that keeps it honest: some paths still rewrite. A client arriving through a
tailnet subnet router gets masqueraded at the routing node even with `Local` set - it lands as
`world` regardless. Some envelopes pass through a second depot you don't control.
## When the depot closes, every envelope changes
This week the fleet finished swapping kube-proxy for Cilium's eBPF replacement. Same services, same
addresses, same policies, and a traefik that had answered LAN probes for months went dark, under a
policy nobody touched.
It was never supposed to answer. That traefik fronts zero routes, and the tightest floor guards it: a policy admitting `host`, because kubelet probes need it, and nothing else. But kube-proxy's SNAT
had been dressing LAN probes as `host`: a different depot, a different stamp, the same disease. The
replacement closed the depot. Every envelope arrived carrying its real return address, the probes
landed as `world`, and the policy dropped them, exactly as written. Nothing broke. The policy
finally saw the truth, and the truth matched the original intent: answer no one.
But if you didn't know *why* it used to answer, this is the morning you file an outage ticket and
"fix" a policy that was never wrong. The rewriting machinery is itself a moving part. Change the
plumbing and every identity changes with it, which is why only observed flows stay true, and
remembered ones quietly expire.
## Read the envelope before you write the rule
The real lesson isn't the one-line fix. It's that I wrote a policy for the traffic I *imagined* - clients on the LAN, arriving as themselves - instead of the traffic that *arrived*. The packet's
story gets rewritten at every hop: SNAT here, DNAT there, a masquerade at a routing boundary. The
sender's truth and the receiver's truth are different documents.
So the discipline, before any allow rule exists: **observe first.** Open Hubble, watch the real
flows, and note the identity the traffic actually carries when it reaches the endpoint - `world`,
`host`, `cluster`, a pod label, a CIDR. Then write the rule for *that*. A policy written from the
architecture diagram is a guess; a policy written from observed flows is a fact.
## Secure what arrives, not what was sent
A network policy is a doorman checking return addresses, and the postal system rewrites return
addresses as a matter of course. You don't secure what was sent - nobody ever sees what was sent.
You secure what arrives. Go and look at the envelope first.
*Bitten and fixed on a six-cluster Talos fleet running Cilium 1.19 - kube-proxy replaced by the eBPF
datapath fleet-wide, MetalLB retired, and a standing rule: Hubble before policy, every time.*
+50
View File
@@ -0,0 +1,50 @@
---
title: "Stage every lock before you turn one"
date: 2026-08-02
summary: "Turning off anonymous pulls on the registry a fleet boots from is a change that only fails at the next boot, and a node that can't pull at boot may not come back to tell you. So the flip is split in time: the credential staged inert in every node's config, each node rebooted and proven with a real pull, and the door closed only when the last key has turned."
tags: ["security", "registry", "kubernetes", "talos", "homelab"]
draft: false
hero: "/blog/stage-the-locks.webp"
heroAlt: "A corridor of doors propped open, new lock cylinders and freshly cut keys glowing on a locksmith's bench - every lock staged, none yet turned"
---
Every node in this fleet pulls its images through one registry: a pull-through mirror fronting five upstreams behind a single host. Anonymous read has been on since the day it went up - the bootstrap-era convenience you stop noticing, right up until you write the words *production posture* and realise the registry the entire estate boots from will hand an image to anyone who asks. The fix is not exotic: a named pull identity for the fleet, anonymous off.
And the reflex for shipping a fix like that is the reflex that works for everything else. Make the change, watch it, roll it back if it misbehaves. Flip the policy, delete a pod, watch the pod re-pull. Green means done.
Except this change doesn't fail when you make it. It fails at the *next boot*, and a node that can't pull at boot may not come back to tell you about it. No test run after the flip proves the fleet safe, because the only test that counts is a reboot, and the reboots that count are the ones you didn't plan. So the change gets split in time, the way a locksmith re-keys an occupied building: fit every cylinder and cut every key while the doors are propped open, try each key in its own door, and only when the last key turns do the props come out.
<!-- DIAGRAM: a three-stage timeline, left to right. STAGE: one pull credential fanned out inert into six node-config repos (git icon), the registry door propped open (anonymous read still ON). PROVE: per-node reboot onto the auth, then a gated pull of a mirror-only image through the node's own runtime - three of six ticked, one node parked for hardware repair. FLIP: anonymous read switched off at the registry ONLY at six of six - props out, door latches. Highlight the per-node gate, not the flip. -->
![Stage the credential inert in every node's config, prove each node with a real pull, flip anonymous off only on full coverage](/diagrams/stage-the-locks.svg)
## The fallback you'd bet on doesn't hold
On paper the naive flip looks survivable. Every node's registry config lists the mirror first and the true upstream second, so if the mirror answered a 401 you'd expect the runtime to shrug and pull direct. That expectation is exactly what the red-team refused to stake a fleet on: containerd's fallback behaviour on an auth failure is unreliable in practice - known, numbered upstream issues (#7321, #9997), not a hypothesis. And the trap has good manners. The flip goes green on the day, because every running pod already holds its images. The failure waits, patient, for the next power blip - then turns it into an outage.
So the red-team moved the flip to a window where every reboot is free: a fleet power-on, nodes coming up anyway, someone watching, a stumbling node getting attention instead of becoming a mystery at two in the morning. That's the first half of the split - this class of change never gets to surface its failure on a reboot you didn't choose.
## Cut every key while the doors are propped
The second half is staging, and staging happens well before enforcement. The credential exists now. It lives in an encrypted environment file beside each cluster's config and is substituted into the node's machine configuration when the config renders - the committed file carries a placeholder, never the value. That auth block went into all six node repos in one reviewed pass, while anonymous read stayed on. Inert. A credential the registry never challenges you for does nothing at all; it sits in git, costing nothing, waiting.
Two costs are worth naming while it waits. Committing is free but *loading* isn't: on this platform, mirror endpoints reload live while registry auth only takes effect at a reboot, which is precisely why the flip has to ride reboots that were already happening. And the rotation bill went into the decision record at decision time: this is one shared pull identity across six nodes, so rotating it later means six config edits and a fleet of reboots. Accepted, consciously, in writing, not discovered mid-incident two years from now.
## Try each key in its own door
Then each node proves itself, one at a time. The gate is not "it booted". Booting proves nothing - the doors are still propped, so anonymous pulls succeed regardless, and cached images would mask a broken credential anyway. The gate is a real pull, driven through the node's own container runtime via the management API, for an image that exists only on the mirror and nowhere upstream. A pass means one thing: this node presented the new credential and the registry honoured it. No fallback can fake that result, and no cache can serve it.
That's the discipline in a sentence: readiness is never inferred from the node coming back. Readiness is the key turning in the door, observed, per node.
## The props stay in until the last key turns
Where it stands, honestly: three of the six nodes are staged and gated as of this week, all in a single window, and cheaply, because each node's credential reboot rode a reboot that another migration was paying for anyway. Changes that share a reboot cost less than changes that each demand their own; batching them into one window is among the cheapest wins fleet operations offers.
The flip itself is still blocked. Deliberately. One node is parked awaiting a hardware repair, so the door stays propped for everyone until that node passes its gate too. That isn't the plan slipping - it *is* the plan. The asymmetry does all the work: a staged lock you haven't turned costs nothing, while a turned lock with one unproven key costs a boot loop on the worst morning of the quarter. With arithmetic that lopsided, waiting isn't caution. It's reading the numbers.
## Failures that only surface at the next boot
Registry auth is one instance of a wider class: changes whose failure surfaces only at the next boot, restart, or failover - where the machine that would report the failure is the machine the failure takes down. For that class, make-it-and-watch is not a strategy, because there is nothing to watch until rollback is no longer on the table. The split is the strategy. Stage the change inert, in config, where it can be reviewed and reverted for free. Prove it per node, under conditions where failure is cheap and attended. Enforce only on full coverage - never on most of it.
Stage the locks first; turn them last.
*Live on a six-node Talos fleet: a zot pull-through mirror fronting five upstreams at one host, the fleet pull credential staged by sops-encrypted substitution into all six cluster repos, three nodes rebooted and gated with a mirror-only image pull, and anonymous read still on, deliberately, until the sixth key turns.*
+67
View File
@@ -0,0 +1,67 @@
---
title: "Untrusted code belongs in a VM, not a namespace"
date: 2026-08-25
summary: "A container isn't a security boundary - it's a process sharing the host kernel, wearing namespaces. For code you genuinely don't trust, that's not enough. Kata Containers give each pod its own microVM and guest kernel, with the kubectl ergonomics intact. Here's why, and what it costs."
tags: ["security", "kata", "kubernetes", "isolation", "ai-agents"]
hero: "/blog/untrusted-in-a-vm.webp"
heroAlt: "A sealed glowing microVM cube isolated above a darker host substrate, hard boundary between them"
draft: false
---
A container feels like a box. It isn't. It's a normal process on the host, wrapped in namespaces and cgroups so
it *can't see* most of the system - but it's still running on the **host's kernel**, sharing it with every other
container and with the node itself. Namespaces are an isolation *convenience*. They are not a security boundary
against code that is actively trying to get out.
For most workloads that's a fine trade. For code you genuinely don't trust - say, an AI agent executing
arbitrary tasks you didn't write - it isn't. One kernel bug, one container escape, and "isolated workload"
becomes "process on your node with your node's privileges." The boundary you were relying on was never really
there.
## Give it its own kernel
[Kata Containers](https://katacontainers.io) change the shape of the boundary. Instead of running the pod as a
process on the shared kernel, Kata boots each pod inside its own lightweight **virtual machine** - a microVM
with its *own guest kernel*, behind the hardware hypervisor (KVM). Now an escape doesn't land you on the host;
it lands you inside a throwaway VM, with a real CPU-enforced boundary (VT-x) between you and everything else.
The lovely part is that nothing about the *operator* experience changes. It's still a pod. You still
`kubectl apply` it, it still gets scheduled, mounts volumes, shows up in logs - you just set one field
(`runtimeClassName: kata`) and the container runtime quietly boots a VM instead of a namespace. The ergonomics
of Kubernetes, the isolation of a hypervisor.
![Shared-kernel namespaces versus per-pod microVMs](/diagrams/untrusted-in-a-vm.svg)
## How you know it's real
It's easy to *claim* hardware isolation; it's worth *proving*. The cleanest proof is the kernel itself: exec
into the pod and check the kernel version. If it's a different kernel from the host, it is - definitionally - a
different kernel, which means a real VM with its own guest, not a namespace dressed up as one. (On my setup the
guest reports one kernel and the Talos host another; that mismatch is the whole proof.) It needs hardware
virtualisation enabled (VT-x / KVM) - without it, Kata can't boot the guest and fails honestly rather than
silently downgrading.
## The cost is real, so design for it
A VM per pod isn't free. Each one carries a fixed memory overhead for the guest kernel and a cold-start measured
in seconds, not milliseconds. That changes how you plan:
- **It's a throughput tier, not a low-latency one.** Seconds-to-start means batch-shaped work, not
request-per-second serving. Fine for "run this task"; wrong for "answer this instantly."
- **Capacity is designed, not discovered.** Because the untrusted tier is, by definition, the part most likely
to misbehave, it gets a *hard* resource quota - a ceiling on memory and concurrent pods - so a runaway (or
hostile) sandbox can **never starve the control plane**. The scheduler also has to *know* about the per-VM
overhead, or it will quietly over-pack the node and OOM under load. You bound the blast radius with policy,
not hope.
## The honest caveat
Kata, plus a default-deny network around it, is a superb *isolation floor*: it contains an escape and it blocks
the pod from talking to things it shouldn't. But notice what it does **not** do. If the untrusted code is handed
a legitimate tool - a credential, an API it's *allowed* to call - hardware isolation won't stop it from misusing
that tool. The VM contains a break-out; it does nothing about exfiltration through a sanctioned door. That's a
different problem, and it needs a different answer -
[a broker that holds the keys the agent never sees](/blog/broker-pattern).
*Running on a single-node Talos cluster as the untrusted-execution tier of an AI-agent platform: Kata microVMs +
a default-deny floor + a designed capacity ceiling, verified guest-kernel-up.*
+11 -12
View File
@@ -1,7 +1,7 @@
---
title: "Every pod holds a key to a door it never opens"
date: 2026-06-23
summary: "Least privilege for Kubernetes workloads doesn't start with an RBAC role it starts with revoking the API token every pod silently carries, then layering identity, non-root, and Pod Security on top."
summary: "Least privilege for Kubernetes workloads doesn't start with an RBAC role - it starts with revoking the API token every pod silently carries, then layering identity, non-root, and Pod Security on top."
tags: ["kubernetes", "security", "least-privilege", "rbac", "service-accounts"]
draft: false
hero: "/blog/workload-least-privilege.webp"
@@ -10,23 +10,23 @@ heroAlt: "Rows of glowing cube-pods each clutch a keycard, facing a sealed vault
---
Open a shell in almost any Kubernetes pod and look in `/var/run/secrets/kubernetes.io`. There's a token
there a live credential for the cluster API, mounted automatically, signed and ready. Now ask the harder
there - a live credential for the cluster API, mounted automatically, signed and ready. Now ask the harder
question: does this workload ever actually *call* the Kubernetes API? For a media server, a credential vault,
a DNS sinkhole, a search proxy the answer is no. Never. Not once.
a DNS sinkhole, a search proxy - the answer is no. Never. Not once.
So every one of those pods is holding a key to a door it never opens. That's not least privilege. That's a
standing credential waiting to be stolen.
![Workload least privilege each pod gets its own ServiceAccount with no API token, runs non-root with capabilities dropped, inside a namespace whose Pod Security level is pinned; the cluster API it never calls stays out of reach](/diagrams/workload-least-privilege.svg?v=2)
![Workload least privilege - each pod gets its own ServiceAccount with no API token, runs non-root with capabilities dropped, inside a namespace whose Pod Security level is pinned; the cluster API it never calls stays out of reach](/diagrams/workload-least-privilege.svg?v=2)
## Identity first, RBAC second
The reflex when someone says "least-privilege RBAC" is to start writing Roles. But a Role narrows what a
credential *can do* it does nothing about a credential that shouldn't exist. The first move is cheaper and
credential *can do* - it does nothing about a credential that shouldn't exist. The first move is cheaper and
safer: give every workload its **own** ServiceAccount instead of the shared namespace default, and set
`automountServiceAccountToken: false`.
That single line changes no runtime behaviour no capabilities, no user, no restart semantics because
That single line changes no runtime behaviour - no capabilities, no user, no restart semantics - because
the app wasn't using the token anyway. You've simply stopped handing out a key. The handful of components
that genuinely talk to the API (the GitOps controller, the load-balancer, the secrets operator) get a
scoped Role and keep their token. Everything else gets an identity and nothing else. It's the highest
@@ -39,25 +39,24 @@ full Linux capability set is one escape away from the host, regardless of how fe
So the next layer is the container itself: run as a non-root UID, drop **all** capabilities and add back only
the few a workload truly needs, forbid privilege escalation, and pin a `RuntimeDefault` seccomp profile.
This is where honesty matters. Some images anything running several processes under an init system, or the
classic "start as root, drop to a user" pattern break the moment you drop capabilities, because something
This is where honesty matters. Some images - anything running several processes under an init system, or the
classic "start as root, drop to a user" pattern - break the moment you drop capabilities, because something
inside still expects to `chown` a directory or bind a port. The lesson isn't "give up"; it's that non-root is
a *per-app, verified* change, while own-identity-and-no-token is a *universal, safe* one. Do the free thing
everywhere; do the careful thing where you've tested it.
## Make the floor non-negotiable
Authoring all of this per workload is good, but authoring isn't enforcing a future manifest can forget. The
Authoring all of this per workload is good, but authoring isn't enforcing - a future manifest can forget. The
enforcement floor is **Pod Security Admission**, set at the namespace. Pin each namespace's level explicitly
rather than inheriting a cluster default you can't see: `baseline` where root init-containers still live,
`restricted` for the apps that have earned it, and a documented `privileged` exception for the genuine cases
(a VPN sidecar that needs `NET_ADMIN`, a time daemon that needs `SYS_TIME`). Run the rest at `warn` for the
restricted profile, and every apply tells you exactly which workload is one refactor away from the top tier.
## The principle
## Least privilege is a direction, not a pile of Roles
Least privilege isn't a pile of Roles. It's a direction: start by removing the access that nothing is using
the auto-mounted token first — then narrow what genuinely remains, then make the boundary enforce itself. Give
Least privilege isn't a pile of Roles. It's a direction: start by removing the access that nothing is using - the auto-mounted token first - then narrow what genuinely remains, then make the boundary enforce itself. Give
every workload its own identity, hand it no credential it doesn't need, strip it to the smallest process it can
be, and let the namespace refuse anything bigger. The credential you never mount can't leak, can't be replayed,
and can't be the thing you forgot to revoke.
+19 -15
View File
@@ -1,12 +1,13 @@
---
title: "Single-Touch Edge AI Platform"
outcome: "Turned a high-level edge-AI design into a single-press deployment running on Kubernetes at the store edge."
summary: "Store-edge Kubernetes running GPU-backed AI workloads, deployed from one command, with readiness-gated GPUs so inference never starts before the hardware is ready."
outcome: "Turned a high-level edge-AI design into a single-touch deployment running on Kubernetes at the store edge."
summary: "Store-edge Kubernetes running GPU-backed AI workloads, deployed from one command, with an init gate that holds the inference container until the GPU answers."
role: "Infrastructure / DevOps Engineer · Woolworths"
period: "2025 Present"
period: "2025 - Present"
stack: ["Kubernetes", "Edge", "NVIDIA GPU", "CD pipelines", "Helm", "Python"]
featured: true
order: 10
cover: "edge-ai-platform"
diagram: "edge-ai"
---
@@ -15,14 +16,15 @@ diagram: "edge-ai"
Edge AI at retail scale lives or dies on repeatability. A computer-vision workload that
runs perfectly in a lab has to come up the same way in a store with no on-site engineer,
flaky connectivity, and a GPU that may not be ready the instant Kubernetes wants to schedule
against it. The starting point was a high-level design and a pile of manual steps exactly
against it. The starting point was a high-level design and a pile of manual steps - exactly
the gap between "it works" and "it ships."
## Constraints
- **No hands at the edge.** Deployment has to be hands-off and idempotent a single press.
- **GPU timing.** Inference pods must never schedule before the GPU device plugin is healthy,
or they crash-loop and poison the rollout.
- **No hands at the edge.** Deployment has to be hands-off and idempotent - a single press.
- **GPU timing.** The kubelet can report `Ready` before the NVIDIA device plugin has advertised
`nvidia.com/gpu`. An inference container that starts in that window crash-loops and poisons
the rollout.
- **Heterogeneous stores.** Per-site variables (network, hardware, identity) without forking
the platform for every location.
@@ -34,21 +36,23 @@ store-edge Kubernetes cluster via Helm with end-state manifests. Per-store confi
injected from a single source of truth, so one pipeline produces a correct deployment for
any site.
The load-bearing piece is **readiness gating**: Bash/Shell probes and Kubernetes watchdogs
confirm the GPU device plugin is up *before* inference pods are allowed to run, and pod
lifecycle management keeps the workload honest from there.
Three separate controls do three separate jobs. A `nvidia.com/gpu` resource request decides
*where* the pod lands. An **init container** blocks until `nvidia-smi` enumerates a device, so the
inference container cannot start before the hardware answers - and it fails loudly, with a bound,
rather than waiting forever. Runtime probes and watchdogs then track health *after* start.
## Security & reliability decisions
- **Init-gated GPU readiness** the single biggest reliability win; no more pods racing the
GPU at boot.
- **Single source of truth** for config — drift can't creep in store-to-store.
- **Spec-driven, documented-as-code** — the deployment *is* the documentation.
- **Init-gated GPU startup** - the single biggest reliability win; the app container no longer
races the device plugin at boot.
- **Single source of truth** for config, rendered through templates - the class of divergence
where two stores disagree on the same value fails at render time rather than in production.
- **Spec-driven, documented-as-code** - the deployment *is* the documentation.
## Outcome
A high-level idea becomes a real, repeatable deployment on a single press. New edge sites
come up consistently, GPUs come online reliably, and the manual runbook is gone replaced
come up consistently, GPUs come online reliably, and the manual runbook is gone - replaced
by a pipeline anyone on the team can trigger.
## Future improvements
+74
View File
@@ -0,0 +1,74 @@
---
title: "The Exploded Cluster"
outcome: "A scroll-driven teaching site that takes container platforms apart one machine at a time."
summary: "A single-page course on how Kubernetes and OpenShift delivery actually works - each subject drawn as one exploded illustration, sliced into its real components and wired to the scroll, then shipped through the same GitOps and CSP discipline as the rest of the estate."
role: "Author / Engineer"
period: "2026"
stack: ["Dependency-free static build", "GSAP ScrollTrigger", "Strict CSP", "Cloudflare Tunnel", "Argo CD", "zot mirror"]
featured: true
order: 25
cover: "exploded-cluster"
links:
- label: "learn.bztmon.com"
href: "https://learn.bztmon.com"
---
## Problem
Container platforms are usually taught as bullet points. A reader finishes knowing the nouns -
kubelet, CRI, Service, chart - without a mental model of how the parts sit together or which
one owns which job. I wanted the opposite: a page where the machine comes apart in front of
you, and the words explain the piece you are looking at.
## Constraints
- **One page, no framework.** The site is a static artefact - no React, no client router. The
animation engine and the content build had to be hand-rolled and small enough to reason about.
- **Same security posture as everything else.** `script-src 'self'`, no inline JavaScript, no
third-party origins - which rules out most off-the-shelf scroll libraries and every CDN.
- **The illustrations are raster.** Each scene is a single generated image, not an SVG scene
graph, so "exploding" it means slicing one bitmap into polygons rather than moving vectors.
## Design
Each subject is one image, cut into per-part polygons with `clip-path`. Every slice is an
absolutely-positioned layer showing the same bitmap through a different window, so the scene
reassembles pixel-exact. Scroll position drives a pinned timeline that translates the slices
between a collapsed stack and the exploded layout.
The parts also carry hidden edges where they overlap in the source art, which constrains the
motion: rotate or fan a slice on a scene where parts overlap and you expose the concealed cut.
Scenes whose parts are already separated take a richer signature - a tumble, a bowed arc, a
spiral orbit - and the rest keep a straight translate. The difference reads as deliberate
rather than repetitive.
The legend beside each scene is wired to the parts. As the scroll advances, the component being
described holds full strength while the others sit back, and hovering or keyboard-focusing a
row pins the link to that part - so what you are reading and what is lit are never different
things.
## Security and reliability decisions
- **No inline script.** The engine is split into a fingerprinted external bundle at build time,
and a preflight gate fails the build if an inline `<script>` survives.
- **Every asset content-fingerprinted.** An earlier revision served a fresh HTML file against a
cached older script and the animations silently died for anyone with a warm cache. Hashing
every asset makes that pairing impossible.
- **Digest-pinned delivery.** The image is built once, pushed through the local zot mirror, and
referenced by digest in the deployment repo; Argo CD reconciles it. Exposure is a Cloudflare
Tunnel, so the origin has no inbound WAN port-forward.
- **Reduced motion is a branch, not a bolt-on.** Under `prefers-reduced-motion` the scroll
scenes render their end state immediately and the parallax backdrop is disabled outright.
## Outcome
The course runs from what an image is through to how a change reaches a fleet, with the
architecture moving while the words explain it. Layout and motion are verified with headless
browser passes rather than by eye - scroll samples across desktop and mobile widths checking
that no pinned scene overlaps the prose beneath it.
## Future improvements
A storage chapter - PersistentVolume, claim and StorageClass are the obvious gap in the current
arc - and a pass that lets a reader jump straight to a single machine rather than scrolling the
whole menu.
@@ -1,25 +1,26 @@
---
title: "Global Infrastructure Modernisation"
outcome: "Modernised a global, multi-region estate at scale ~1,000 VMs, flat-to-segmented networks, and a migration to Azure and Microsoft 365 on a live 24/7 business."
outcome: "Modernised a global, multi-region estate at scale - ~1,000 VMs, flat-to-segmented networks, and a migration to Azure and Microsoft 365 - on a live 24/7 business."
summary: "Across global IT roles at Virtus Health and Linde Asia Pacific: a ~1,000-VM VMware estate managed centrally, a flat-to-segmented network redesign with SD-WAN and Aruba ClearPass, Palo Alto / FortiGate firewall redesigns, and migration to Azure (Blob, AVS) and Microsoft 365."
role: "Infrastructure Engineer · Virtus Health & Linde Asia Pacific"
period: "2019 2025"
period: "2019 - 2025"
stack: ["VMware / vSphere", "Azure (Blob, AVS)", "Microsoft 365", "SD-WAN", "Aruba ClearPass", "Palo Alto / FortiGate", "Veeam"]
featured: false
order: 40
cover: "global-infra-modernisation"
---
## Problem
Enterprise estates accrete. Flat networks, sprawling VM counts, aging firewalls, and
on-prem-only services become a security and operations drag. The work: modernise a global,
multi-region business that runs 24/7 without breaking it.
multi-region business that runs 24/7 - without breaking it.
## Constraints
- **Keep the lights on** change a live, multi-region estate without downtime.
- **Security and compliance** segmentation, patching, and auditability throughout.
- **Cost-aware** modernise to cloud where it pays, justified through CapEx/OpEx cases.
- **Keep the lights on** - change a live, multi-region estate without downtime.
- **Security and compliance** - segmentation, patching, and auditability throughout.
- **Cost-aware** - modernise to cloud where it pays, justified through CapEx/OpEx cases.
## Design
@@ -27,10 +28,10 @@ Across global roles I ran and improved a **~1,000-VM VMware estate**, managed ce
the IT team and operated across regions including the UK. I re-segmented **flat sites into
isolated VLAN ranges** with ACLs, layering in **SD-WAN** and **Aruba ClearPass** with 802.1x
onboarding for a tiered, authenticated network. **Palo Alto / FortiGate** firewalls were
upgraded and redesigned around the new segmentation RCA, staging through FortiManager, and
upgraded and redesigned around the new segmentation - RCA, staging through FortiManager, and
a flat-to-segmented redesign.
On the platform side: workloads and identity moved to **Azure** (Blob storage, AVS lifting
On the platform side: workloads and identity moved to **Azure** (Blob storage, AVS - lifting
existing vSphere environments) and **Microsoft 365**, with a **hybrid AD sync** I architected
to bridge on-prem and cloud identity. The estate work also covered an **ERP hardware refresh
with a new DR / mainframe solution**, file shares to Azure Blob over Kerberos auth, **Veeam**
@@ -38,14 +39,14 @@ backups, and a region-wide **PBX-to-VoIP** migration (RingCentral).
## Security & reliability decisions
- **Flat → segmented** isolation by design, not by exception.
- **Authenticated access** (ClearPass, 802.1x) the network knows who's on it.
- **Patched, current firewalls** closing the easy doors first.
- **DR built in** recovery designed, not assumed.
- **Flat → segmented** - isolation by design, not by exception.
- **Authenticated access** (ClearPass, 802.1x) - the network knows who's on it.
- **Patched, current firewalls** - closing the easy doors first.
- **DR built in** - recovery designed, not assumed.
## Outcome
A more secure, segmented, cloud-leaning estate that's cheaper to run and easier to operate
A more secure, segmented, cloud-leaning estate that's cheaper to run and easier to operate -
delivered against live-business constraints across multiple regions.
## Future improvements
+15 -13
View File
@@ -1,12 +1,13 @@
---
title: "GPU-as-Code on the Edge"
outcome: "Brought GPUs online as code passthrough, readiness-gated, and reproducible across the fleet."
summary: "GPU passthrough configured through ESXi via code with end-state manifests and Helm, paired with readiness probes, watchdogs, and DCGM-based health reporting."
outcome: "Brought GPUs online as code - passthrough, init-gated at startup, and reproducible across the fleet."
summary: "GPU passthrough configured through ESXi via code with end-state manifests and Helm, paired with an init gate on GPU availability, runtime watchdogs, and DCGM-based health reporting."
role: "Infrastructure / DevOps Engineer"
period: "2025 Present"
period: "2025 - Present"
stack: ["GPU passthrough", "ESXi", "DCGM Exporter", "Prometheus", "Bash", "Watchdogs"]
featured: false
order: 30
cover: "gpu-as-code"
---
## Problem
@@ -17,29 +18,30 @@ to refuse to start until both are true. Doing that by hand, per site, doesn't sc
## Constraints
- **As-code, not click-ops** GPU passthrough defined in code, not the ESXi UI.
- **Fail safe** a not-ready GPU must block the workload, not crash it.
- **Observable** GPU health has to be visible alongside the rest of the platform.
- **As-code, not click-ops** - GPU passthrough defined in code, not the ESXi UI.
- **Fail safe** - a not-ready GPU must block the workload, not crash it.
- **Observable** - GPU health has to be visible alongside the rest of the platform.
## Design
GPU passthrough is configured through ESXi **via code**, with end-state manifests and Helm
charts describing the desired node. In-cluster, **Bash/Shell readiness probes** and
Kubernetes **watchdogs** gate inference pods on a healthy GPU device plugin and manage pod
lifecycle from there. **DCGM Exporter** feeds GPU and container-workload health into
charts describing the desired node. In-cluster, an **init container** holds the inference
container until the GPU enumerates, and **runtime probes and watchdogs** track health from
there - startup gating and health checking kept as separate concerns. **DCGM Exporter** feeds GPU and container-workload health into
Prometheus and AWX job-level reporting, so a degraded GPU surfaces the same way any other
platform signal does.
## Security & reliability decisions
- **Readiness gating** — pods wait for the hardware; no boot-time races.
- **End-state manifests** the node's GPU config is declarative and reproducible.
- **DCGM telemetry** GPU failures are detected, not discovered.
- **Init gating** - the app container waits for the hardware, so there are no boot-time races.
- **End-state manifests** - the node's GPU config is declarative and reproducible.
- **DCGM telemetry** - GPU failures are detected, not discovered.
## Outcome
GPUs come online predictably across the fleet, the dangerous "pod started before the GPU"
class of failure is designed out, and GPU health is a first-class metric.
class of failure is addressed at startup rather than left to retries, and GPU health is
reported alongside every other platform signal.
## Future improvements

Some files were not shown because too many files have changed in this diff Show More