commit 9f923b4f35fc5b4b9c3c741683c8422c52fddd32 Author: momme Date: Tue Oct 13 20:34:54 2026 +0200 momme.world: terminal-style personal site, nginx + ConfigMap deploy Static site (no build step) with a browser terminal, plain-text output for curl, and plain k8s manifests under k8s/ for ArgoCD. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..816d8f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.svg text eol=lf diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2b4854 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# momme.world + +Personal site at [momme.world](https://momme.world) — a terminal-style page +that behaves like a small shell (`whoami`, `projects`, `social`, …), plus a +plain-text rendering for `curl momme.world`. + +## Layout + +``` +k8s/ + kustomization.yaml the whole deployment: generated ConfigMaps + refs + deployment.yaml one unprivileged nginx, content served from ConfigMaps + service.yaml ClusterIP, :80 -> :8080 + site/ the actual website, served as-is + index.html markup + noscript fallback + style.css + site.js the terminal; ALL site content lives in COMMANDS here + nginx.conf mounted as /etc/nginx/conf.d/default.conf + hello.txt what curl gets +``` + +There is deliberately **no build step**: no package.json, no bundler, no CI. +Edit a file under `k8s/site/`, merge to `main`, ArgoCD syncs it. The content +of the site is the repo — the kustomize `configMapGenerator` hashes the files +into their ConfigMap names, which rolls the Deployment automatically. + +## Editing the site + +- New terminal command: add one object to `COMMANDS` in `k8s/site/site.js`. + It appears in `help` and in tab-completion automatically. `label -> url` + lines become links. +- The noscript block in `index.html` mirrors the main facts by hand; keep it + in sync when changing `whoami`/`contact` content. +- `curl`-UA detection is the `$root_doc` map in `k8s/site/nginx.conf`. + +## How it deploys + +The `momme/infra` module `app-momme-world` owns the namespace, the TLS +certificate and the HTTPProxy, and creates an ArgoCD `Application` that points +at `k8s/` in this repo. Nothing in this repo talks to the cluster. + +## Local preview + +Any static server works, e.g.: + +``` +cd k8s/site && python -m http.server 8080 +``` + +## Open decisions + +Things left deliberately open for you: + +1. **DNS** — `momme.world` currently has *no A record*. The zone lives in + Cloudflare (nameservers `fred/jessica.ns.cloudflare.com`), the same place + `momme.cloud` lives. An `A momme.world -> 31.18.25.47` (grey cloud, the + same public entry all `*.momme.cloud` services use) is all that's needed. + Also check that the Cloudflare API token behind the `cf` ClusterIssuer + (Infisical `/global`, `cloudflare_api_token`) is allowed to do DNS-01 in + the momme.world zone — if it's scoped to momme.cloud only, widen it. +2. **www redirect** — not implemented. If you want `www.momme.world` to work, + add a second DNS name + HTTPProxy route (infra PR can carry it). +3. **Analytics / visits** — none included. Self-hosted (Plausible) or none is + an easy later call; nothing in this repo blocks it. +4. **Blog later** — you mentioned a Markdown-driven blog could be nice + someday. The current no-build design can take it as far as "one static + page per post written in HTML"; as soon as you want real Markdown + + templates, the natural step is a generator (Lume like adb.sh, or + Hugo) plus a built image. Decision deferred, structure unaffected. +5. **Content facts** — taken from the old site's commands (work at + codeanker, projects list, socials). Say the word for anything outdated; + they all live in `COMMANDS` in `site.js`. diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..30ba92b --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: momme-world + labels: + app: momme-world +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: momme-world + template: + metadata: + labels: + app: momme-world + spec: + securityContext: + runAsNonRoot: true + runAsUser: 101 + fsGroup: 101 + seccompProfile: + type: RuntimeDefault + containers: + - name: nginx + image: docker.io/nginxinc/nginx-unprivileged:1.31.6-alpine@sha256:884e0b7f42f303641b0f4e78755aea647521dca0b89ca3be7992ad78b5c07434 + ports: + - name: http + containerPort: 8080 + volumeMounts: + - name: site + mountPath: /var/www/html + readOnly: true + - name: config + mountPath: /etc/nginx/conf.d/default.conf + subPath: nginx.conf + readOnly: true + - name: tmp + mountPath: /tmp + readinessProbe: + httpGet: + path: /hello.txt + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /hello.txt + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: site + configMap: + name: site + - name: config + configMap: + name: config + - name: tmp + emptyDir: {} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..f67439e --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: app-momme-world +resources: + - deployment.yaml + - service.yaml +configMapGenerator: + - name: site + files: + - site/index.html + - site/style.css + - site/site.js + - site/hello.txt + - site/favicon.svg + - site/robots.txt + - name: config + files: + - site/nginx.conf diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..0184c49 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: momme-world + labels: + app: momme-world +spec: + selector: + app: momme-world + ports: + - name: http + port: 80 + targetPort: http diff --git a/k8s/site/favicon.svg b/k8s/site/favicon.svg new file mode 100644 index 0000000..9fcb624 --- /dev/null +++ b/k8s/site/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/k8s/site/hello.txt b/k8s/site/hello.txt new file mode 100644 index 0000000..014d134 --- /dev/null +++ b/k8s/site/hello.txt @@ -0,0 +1,39 @@ + + __ __ __ . + | \/ | ___ _ __ ___ _ __ \ \ __ | | ___ ___ + | |\/| |/ _ \| '_ ` _ \| '_ \ \ \ / _` |/ _ \ / _ \ + | | | | (_) | | | | | | | | | \ V (_| | (_) | __/ + |_| |_|\___/|_| |_| |_|_| |_| \_/\__,_|\___/ \___| + +Hey :) +I'm Momme. I build and break infrastructure, and I keep the pieces in +Kubernetes so they can be put back together again. + +whoami +------ +Momme Jürgensen — infrastructure and developer platforms. +I like work that ends up as a short command instead of a long document. +Currently at codeanker. + +stack +----- +kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest +nix reproducible configs for servers and laptops +observability Prometheus, Loki, Grafana — dashboards I actually read +fpv drones built, flown, crashed, rebuilt + +projects +-------- +nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on +brahmsee.digital event management platform for DLRG Schleswig-Holstein +silvatica-web website of the Silvatica festival, Schwäbisch Hall +momme.world this site — no build step, GitOps all the way down + +contact +------- +mail momme@juergensen.me +matrix @momme:chaos.jetzt +github https://github.com/supermomme +mastodon https://cybre.town/@momme + +The interactive version of this page is at https://momme.world/ diff --git a/k8s/site/index.html b/k8s/site/index.html new file mode 100644 index 0000000..2d63be7 --- /dev/null +++ b/k8s/site/index.html @@ -0,0 +1,91 @@ + + + + + +momme.world — Momme Jürgensen + + + + + + + + + + + + +
+ +
+
+ + momme@world: ~ +
+ +
+
+  __  __                         __        .
+ |  \/  | ___  _ __ ___  _ __    \ \   __ | | ___   ___
+ | |\/| |/ _ \| '_ ` _ \| '_ \    \ \ / _` |/ _ \ / _ \
+ | |  | | (_) | | | | | | | | |    \ V  (_| | (_) |  __/
+ |_|  |_|\___/|_| |_| |_|_| |_|     \_/\__,_|\___/ \___|
+      
+ +
+ +
+ + + +
+
+
+ + + +

+ Terminal people: curl momme.world +  ·  source: github.com/supermomme +

+ + + + +
+ + + diff --git a/k8s/site/nginx.conf b/k8s/site/nginx.conf new file mode 100644 index 0000000..d4e1a8d --- /dev/null +++ b/k8s/site/nginx.conf @@ -0,0 +1,42 @@ +# Mounted at /etc/nginx/conf.d/default.conf — everything here is http context. + +# terminal people get the plain text version of the page +map $http_user_agent $root_doc { + default /index.html; + "~*(curl|wget|httpie|lynx|w3m|nc/)" /hello.txt; +} + +server { + listen 8080; + server_name _; + + root /var/www/html; + index index.html; + absolute_redirect off; + + gzip on; + gzip_types text/css application/javascript text/plain image/svg+xml; + gzip_vary on; + + # index.html is the app itself: never cache it, so a deploy is visible now + location = / { + try_files $root_doc =404; + add_header Cache-Control "no-store"; + } + + location = /index.html { + add_header Cache-Control "no-store"; + } + + location = /hello.txt { + add_header Cache-Control "no-store"; + } + + location ~* \.(css|js|svg)$ { + add_header Cache-Control "public, max-age=604800"; + } + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/k8s/site/robots.txt b/k8s/site/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/k8s/site/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/k8s/site/site.js b/k8s/site/site.js new file mode 100644 index 0000000..e2665e2 --- /dev/null +++ b/k8s/site/site.js @@ -0,0 +1,244 @@ +// Content lives in COMMANDS only: every entry renders in the terminal, and the +// names double as the `help` listing. A `label -> url` line becomes a real link. +// Adding a section to this site means adding one object here. +const COMMANDS = [ + { + name: "hello", + help: "the welcome message", + body: `Hey :) +I'm Momme. I build and break infrastructure, and I keep the pieces in +Kubernetes so they can be put back together again. +Type help to see what this box knows.`, + }, + { + name: "whoami", + help: "who is typing", + body: `Momme Jürgensen — infrastructure and developer platforms. +I like work that ends up as a short command instead of a long document. +Currently at codeanker.`, + }, + { + name: "projects", + help: "projects — what I'm building", + body: `nix-ha-kubernetes-cluster high-availability Kubernetes on NixOS, built to learn on +brahmsee.digital event management platform for DLRG Schleswig-Holstein +silvatica-web website of the Silvatica festival, Schwäbisch Hall +momme.world this site — no build step, GitOps all the way down + more of them at: https://github.com/supermomme`, + detail: { + "nix-ha-kubernetes-cluster": `An HA Kubernetes cluster on NixOS, built to learn rather than to impress. +Control plane in HA, Ceph for storage, GitOps for everything above it. +This site runs on it. -> https://github.com/supermomme/nix-ha-kubernetes-cluster`, + "brahmsee.digital": `Event management platform for DLRG Schleswig-Holstein. +Registrations and scheduling for a state water-rescue organisation, where the +traffic arrives in waves of a few hundred people at once. -> https://brahmsee.digital`, + "silvatica-web": `Website of the Silvatica festival in Schwäbisch Hall. +-> https://github.com/supermomme/silvatica-web`, + "momme.world": `This site. Plain files, a stock nginx image, four manifests, no build +step. ArgoCD points at the repo, so a merged commit is a deployed site.`, + }, + }, + { + name: "stack", + help: "the tools I reach for", + body: `kubernetes self-hosted cluster, GitOps with ArgoCD, Terraform for the rest +nix reproducible configs for servers and laptops +observability Prometheus, Loki, Grafana — dashboards I actually read +fpv drones built, flown, crashed, rebuilt`, + }, + { + name: "social", + help: "where I hang out online", + body: `github -> https://github.com/supermomme +mastodon -> https://cybre.town/@momme +instagram -> https://instagram.com/supermommej`, + }, + { + name: "contact", + help: "reach me directly", + body: `mail -> mailto:momme@juergensen.me +matrix -> https://matrix.to/#/@momme:chaos.jetzt`, + }, + { + name: "ls", + help: "what's in here", + body: () => names("projects").join(" "), + }, + { + name: "cat", + prefix: true, + help: "cat README.md", + run: (arg) => { + const f = arg.toLowerCase(); + if (f === "readme.md" || f === "readme") return body("hello"); + if (f === ".ssh/id_rsa") return fail("nice try."); + return fail(`cat: ${arg}: No such file or directory`, "try: cat README.md"); + }, + }, + { + name: "help", + help: "this list", + body: () => ["usage: ", "", ...usages()], + run: (arg) => { + if (!arg) return body("help"); + const row = usages().find((line) => line.trim().startsWith(arg + " ")); + return row ? row : fail(`no command called "${arg}"`, "see: help"); + }, + }, + { name: "clear", help: "wipe the screen", body: "" }, + { name: "date", help: "today's date", body: () => new Date().toDateString() }, + { name: "time", help: "current time", body: () => new Date().toLocaleTimeString("de-DE") }, + { name: "echo", prefix: true, help: "echo — repeat yourself", body: (arg) => arg }, + { name: "pwd", help: "working directory", body: "/world" }, + { name: "uname", prefix: true, help: "system information", body: "momme.world 1.0.0 static x86_64 GNU/freedom" }, + { name: "curl", prefix: true, help: "curl momme.world — you already did that", body: "you are already reading the output :)" }, + { name: "sudo", prefix: true, help: "elevate", body: "momme is not in the sudoers file. This incident has been reported." }, + { name: "vim", prefix: true, help: "an editor", body: "emacs, obviously." }, + { name: "emacs", prefix: true, help: "an OS with good editing", body: "a great OS, shame about the editor." }, + { name: "coffee", help: "brew coffee", body: "() ~ I'm out of coffee." }, + { name: "exit", help: "close the session", body: "close the tab, or just stop reading :)" }, +]; + +const ALIASES = { + whois: "whoami", about: "whoami", openme: "whoami", + tech: "stack", tools: "stack", + links: "social", socials: "social", + mail: "contact", email: "contact", + repos: "projects", rm: "sudo", +}; + +// ---------- content helpers ---------- +const byName = (name) => COMMANDS.filter((c) => c.name === name && !c.prefix)[0]; +const body = (name) => render(byName(name).body, ""); +const render = (value, arg) => { + const v = typeof value === "function" ? value(arg) : value; + return Array.isArray(v) ? v.join("\n") : String(v); +}; +const names = (name) => + split(body(name)).map((line) => line.trim().split(/\s{2,}/)[0]).filter(Boolean); +const commandNames = () => unique().map((c) => c.name); +const unique = () => + COMMANDS.filter((c, i) => COMMANDS.findIndex((x) => x.name === c.name) === i); +const usages = () => unique().map((c) => ` ${c.name.padEnd(9)} ${c.help}`); +const fail = (message, ...more) => ({ error: message, more: more.filter(Boolean) }); +const split = (value) => value.split("\n"); + +// ---------- dom ---------- +const out = document.getElementById("out"); +const screen = document.getElementById("screen"); +const form = document.getElementById("prompt"); +const input = document.getElementById("cmd"); + +const make = (tag, cls, value) => { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (value != null) node.textContent = value; + return node; +}; + +const scrollDown = () => (screen.scrollTop = screen.scrollHeight); + +function print(value, arg) { + const resolved = typeof value === "function" ? value(arg) : value; + if (resolved && resolved.error) { + out.append(make("div", "line err", resolved.error)); + resolved.more.forEach((line) => out.append(make("div", "line dim", line))); + } else { + split(render(resolved, arg)).forEach((line) => out.append(linked(line))); + } + scrollDown(); +} + +// Turn `label -> url` (or a bare url) into text plus real anchors. +const LINK = /(https?:\/\/\S+|mailto:\S+)/; + +function linked(line) { + const node = make("span", "line out"); + if (!LINK.test(line)) return (node.textContent = line), node; + const arrow = line.indexOf("->"); + if (arrow > 0) node.append(line.slice(0, arrow)); + (arrow > 0 ? line.slice(arrow + 2) : line).split(LINK).forEach((part) => { + if (!LINK.test(part)) return node.append(part); + const anchor = make("a", null, part); + anchor.href = part; + if (part.startsWith("http")) anchor.rel = "noopener me"; + node.append(anchor); + }); + return node; +} + +// ---------- the loop ---------- +const history = { items: [], at: 0 }; + +function recall(step) { + history.at += step; + if (history.at < 0) history.at = 0; + if (history.at >= history.items.length) { + history.at = history.items.length; + input.value = ""; + return; + } + input.value = history.items[history.at]; +} + +function execute(raw) { + const [word = "", ...rest] = raw.trim().split(/\s+/); + const arg = rest.join(" "); + const name = word.toLowerCase(); + + out.append(make("div", "line cmd", raw)); + if (!name) return scrollDown(); + if (name === "clear") return (out.textContent = ""); + + const wanted = ALIASES[name] || name; + const found = + COMMANDS.find((c) => c.name === wanted && !c.prefix) || + COMMANDS.find((c) => c.name === wanted && c.prefix); + + if (!found) { + const near = commandNames().filter((n) => n !== name && n[0] === name[0]).slice(0, 3); + return print(fail(`command not found: ${name}`, + near.length ? `did you mean: ${near.join(", ")}` : "see: help")); + } + + if (found.name === "projects" && arg) { + const key = arg.toLowerCase(); + if (key === "list") return print(body("projects")); + return print(found.detail[key] || + fail(`no project called "${arg}"`, "names:", ...names("projects"))); + } + + return print(found.run ? found.run(arg) : found.body, arg); +} + +form.addEventListener("submit", (event) => { + event.preventDefault(); + const raw = input.value; + input.value = ""; + if (raw.trim()) history.items.push(raw.trim()); + history.at = history.items.length; + execute(raw); +}); + +input.addEventListener("keydown", (event) => { + if (event.key === "ArrowUp") { event.preventDefault(); recall(-1); } + if (event.key === "ArrowDown") { event.preventDefault(); recall(1); } + if (event.key !== "Tab") return; + const word = input.value.trim().split(/\s+/)[0]; + const hit = commandNames().filter((n) => word && n.startsWith(word)); + if (hit.length === 1) { event.preventDefault(); input.value = hit[0] + " "; } +}); + +screen.addEventListener("click", () => { + if (!getSelection().toString()) input.focus(); +}); + +document.querySelectorAll("[data-run]").forEach((button) => + button.addEventListener("click", () => { + execute(button.dataset.run); + input.focus(); + }) +); + +print(`hello — type help for the rest of it.\n`); +input.focus(); diff --git a/k8s/site/style.css b/k8s/site/style.css new file mode 100644 index 0000000..53a5a7a --- /dev/null +++ b/k8s/site/style.css @@ -0,0 +1,136 @@ +:root { + --bg: #0b0e14; + --panel: #11151f; + --border: #232b3a; + --fg: #d6deeb; + --dim: #7c879b; + --accent: #7ee787; + --accent-2: #6cb6ff; + --warn: #f2cc60; + --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Mono", + Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; } + +body { + background: var(--bg); + color: var(--fg); + font-family: var(--mono); + font-size: 15px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + display: flex; + justify-content: center; + padding: clamp(12px, 4vw, 48px) clamp(10px, 3vw, 24px) 40px; +} + +main { width: 100%; max-width: 46rem; } + +.term { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); +} + +.titlebar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: #151a26; + border-bottom: 1px solid var(--border); + font-size: 12px; + color: var(--dim); +} +.dot { width: 11px; height: 11px; border-radius: 50%; } +.dot.r { background: #ff5f57; } +.dot.y { background: #febc2e; } +.dot.g { background: #28c840; } +.title { margin-left: 8px; } + +.screen { + padding: 18px clamp(12px, 2.5vw, 22px) 20px; + min-height: 58vh; + max-height: 78vh; + overflow-y: auto; + overscroll-behavior: contain; + outline: none; +} + +.hello { + color: var(--accent-2); + font-size: clamp(6.5px, 1.55vw, 12px); + line-height: 1.25; + margin: 0 0 18px; + overflow-x: auto; +} + +#out { white-space: pre-wrap; word-break: break-word; } + +.line { margin: 0; display: block; } +.line.cmd { color: var(--fg); } +.line.cmd::before { content: "momme@world:~$ "; color: var(--dim); } +.line.out { color: var(--fg); margin-bottom: 6px; } +.line.dim { color: var(--dim); } +.line.err { color: #ff6b6b; } +a { color: var(--accent-2); text-decoration: none; } +a:hover, a:focus-visible { text-decoration: underline; } +.hl { color: var(--accent); } +.key { color: var(--warn); } + +.cursor { + display: inline-block; + width: 8px; + height: 1.05em; + background: var(--accent); + vertical-align: text-bottom; + animation: blink 1.1s steps(1) infinite; +} +@keyframes blink { 50% { opacity: 0; } } + +#prompt { display: flex; align-items: baseline; gap: 10px; margin-top: 4px; } +.ps1 { color: var(--dim); white-space: nowrap; } +#cmd { + flex: 1; + min-width: 0; + background: none; + border: 0; + outline: none; + color: var(--fg); + font: inherit; + caret-color: var(--accent); +} +#cmd::placeholder { color: #4b5566; } + +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; } +.chips button { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--dim); + font: inherit; + font-size: 13px; + padding: 5px 14px; + cursor: pointer; + transition: color .15s, border-color .15s; +} +.chips button:hover, .chips button:focus-visible { + color: var(--accent); + border-color: var(--accent); + outline: none; +} + +.curl { color: var(--dim); font-size: 13px; margin: 16px 2px 0; } +.curl code { background: var(--panel); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; } + +.facts h2 { font-size: 15px; color: var(--accent); margin: 26px 0 8px; } +.facts ul { padding-left: 20px; } + +@media (prefers-reduced-motion: reduce) { + .cursor { animation: none; } +}