Ein CI-Broker für Radicle, der wirklich baut — die Reise durch jede Wand

Vor zwei Wochen stand der Plan: ein CI-Broker, der auf Patch-Events meiner Radicle-Seed-Node hört, in-cluster baut und signiert. Das Diagramm war sauber. Die Realität war ein wochenlanger Kampf mit cib-Entrypoints, Flux, das meine Shell-Variablen auffraß, stale :latest-Tags, rootless buildah, das in Kubernetes prinzipiell nicht geht, und einem binfmt_misc-Handler, der auf Kernel 6.x immer wieder verschwand. Hier ist jede Wand, gegen die ich gelaufen bin — und wie der Broker am Ende doch baut.
Table of contents

In Radicle CI — eine Pipeline, die auf Patches reagiert habe ich den Plan beschrieben: Meine Seed-Node läuft in-cluster und führt einen Event-Stream — jede neue Patch-Ref, jedes COB-Update wird sichtbar. Ein CI-Broker abonniert diesen Stream, übersetzt ein Patch-Event in einen Build und schreibt das Ergebnis zurück auf den Patch. Sauberes Diagramm, klare Idee, ein offenes Trust-Problem, das ich für lösbar hielt.

Was dieser Beitrag erzählt, ist die Lücke zwischen dem Diagramm und einem grünen Häkchen. Sie war größer, als mir lieb war. Zwischen „der Broker ist deployt" und „der Broker baut wirklich ein signiertes Image" lagen rund zwei Dutzend Commits, die fast alle fix: heißen — und jeder davon steht für eine Wand, gegen die ich gelaufen bin. Das hier ist die ehrliche Version: kein Architektur-Pitch, sondern ein Reisebericht durch Entrypoints, die nicht stimmen, durch Flux, das meine Shell-Variablen frisst, durch rootless buildah, das in Kubernetes prinzipiell nicht funktioniert, und durch einen Kernel, der meinen QEMU-Handler immer wieder vergaß.

Die Besetzung

Bevor es schmerzt, kurz das Personal. Der Broker selbst ist cib — der radicle-ci-broker von Lars Wirzenius. cib hört auf Node-Events, filtert sie und ruft pro Treffer einen Adapter auf. Ich fahre den native Adapter: Er checkt das Repo aus und führt die Build-Schritte direkt im Broker-Container aus — keine Wegwerf-VM, kein Ambient-Runner. Das ist bewusst minimal und macht den Trust-Filter (dazu unten) zur einzigen Sicherheitsgrenze.

Drumherum läuft das Ganze als ein Pod mit drei Containern auf meinem arm64-Talos-Cluster:

              radicle namespace   (PSA: privileged)
┌───────────────────────────────────────────────────────────────────────┐
│                                                                       │
│  Pod: ci-broker   (strategy: Recreate, ein Writer auf RWO + Socket)   │
│  ┌──────────────┐                     ┌─────────────────────────┐     │
│  │ ci-node      │   control.sock      │ cib (broker)            │     │
│  │ eigene NID   │<───────────────────>│ native adapter:         │     │
│  │ client-node  │ RefsFetched Events  │ checkout + build.sh     │     │
│  └───┬──────────┘                     │ buildah > cosign > Zot  │     │
│      │ :8776 (in-cluster)             └────────────┬────────────┘     │
│      │                                             │ reports/         │
│  ┌───┬─────────────────────────┐      ┌────────────┬────────────┐     │
│  │ seed-node.radicle.svc:8776  │      │ caddy (web)   :8080     │     │
│  │ (NICHT seed.this-is-fine.io)│      │ ci.<domain>/ read-only  │     │
│  └─────────────────────────────┘      └─────────────────────────┘     │
│                                                                       │
└───────────────────────────────────────────────────────────────────────┘

  Außerhalb des Pods:
   - kube-system/binfmt DaemonSet (privileged) registriert qemu-x86_64 im Host-Kernel
   - PVCs:  ci-broker        (RWO, state: cib.db + RAD_HOME + reports)
            ci-broker-build  (100Gi scratch, ceph-block, NICHT VolSync)

Drei Container: ein dedizierter ci-node mit eigener Identität, der die CI-Repos seedet und Events emittiert; cib, der filtert und baut; caddy, der die statischen Reports read-only unter ci.<domain> ausliefert. Klingt überschaubar. Genau hier fing das Leiden an.

Akt 1 — Der Broker läuft nicht einmal an

cib ist one-shot, das Image-Entrypoint war es nicht

Erste Überraschung: cib (0.28) ist kein Daemon. Der relevante Subcommand ist process-events, und der läuft einmal durch und beendet sich. Das Image-Entrypoint passte nicht zu dem, was ich brauchte, und der Container ging in CrashLoop. Statt auf ein neues Image zu warten, habe ich das Entrypoint im Deployment überschrieben: erst auf den Control-Socket der Node warten, dann process-events in einer Schleife:

 1set -eu
 2mkdir -p /ci-broker/cib/reports/http /ci-broker/run
 3sock="${RAD_HOME}/node/control.sock"
 4echo "[cib] waiting for node control socket at ${sock}.."
 5until [ -S "${sock}" ]; do sleep 2; done
 6echo "[cib] node socket present; entering process-events loop"
 7while true; do
 8  cib --config "${CIB_CONFIG}" process-events || echo "[cib] exited $?; retrying"
 9  sleep "${CIB_INTERVAL:-30}"
10done

Das until [ -S "$sock" ] ist nicht kosmetisch: ci-node und cib starten im selben Pod parallel, und cib stirbt sofort, wenn der Socket noch nicht da ist. Ohne die Warteschleife verliert man das Race in vielleicht jedem dritten Rollout.

Flux frisst meine Shell-Variablen

Dann ein Fehler, der mich eine Weile gekostet hat, weil er so unscheinbar war: ${RAD_HOME} war im laufenden Container leer. Der Socket-Pfad war plötzlich /node/control.sock statt /ci-broker/radicle/node/control.sock, die Warteschleife wartete auf Godot.

Die Ursache ist die Flux-postBuild-Substitution. Flux ersetzt ${...} in den Manifesten durch Cluster-Variablen (${EXTERNAL_DOMAIN} und Co.) — und macht keinen Unterschied zwischen meinen Template-Variablen und einer Shell-Variable, die erst zur Laufzeit im Container existieren soll. ${RAD_HOME} wurde von Flux zu Deploy-Zeit durch nichts ersetzt. Die Lösung ist, jede Shell-Variable im inline-Skript zu verdoppeln, damit Flux sie in Ruhe lässt:

1sock="$${RAD_HOME}/node/control.sock"
2until [ -S "$${sock}" ]; do sleep 2; done
3cib --config "$${CIB_CONFIG}" process-events || echo "exited $$?"
Sobald ein inline-Shell-Skript in einem Flux-Kustomization-Manifest steckt, gehört jede Laufzeit-Shell-Variable als $$VAR geschrieben — $${RAD_HOME}, $$?, $${sock}. ${VAR} mit einfachem $ ist für Flux ein Substitutions-Token und wird zu Deploy-Zeit ausgewertet (meist zu Leerstring). Das betrifft nicht nur dieses Projekt; es ist die generische Falle bei „Skript im GitOps-Manifest".

„cib config" ist nicht „cibtool config"

Eine Kleinigkeit, die viel Verwirrung sparte, als ich sie endlich verstand: Um die gemountete cib.yaml zu validieren, ruft man cib --config … config auf — nicht cibtool config. cibtool ist das Operator-CLI (Run-Historie, Event-Queue, manuelle Trigger), config ist ein Subcommand von cib selbst. Mein erster Validierungs-Task rief das Falsche auf und schlug mit einer nichtssagenden Meldung fehl. Seitdem hängt der richtige Aufruf als task radicle:ci-config im Repo, mit genau diesem Hinweis im Kommentar.

Akt 2 — Das Event kommt nie an

Der Broker lief. Es passierte trotzdem nichts. Ein Patch ging raus, ich wartete auf einen Build — und der Event-Stream blieb stumm. Dieser Akt drehte sich komplett um Replikation.

Eine eigene Identität für die CI-Node, und der NAT-Hairpin

cib braucht eine Radicle-Node, die die CI-Repos seedet und ihm RefsFetched-Events über den Control-Socket schickt. Ich gebe ihr eine eigene Identität (ci-node), getrennt von der Seed-Node — dasselbe Muster wie der ZeroClaw-Sidecar. Diese Node ist ein client-node: Sie lauscht nicht öffentlich, sie verbindet sich nur nach außen.

Der erste Fallstrick steckt im wohin. Naheliegend wäre seed.this-is-fine.io:8776 — die öffentliche Adresse. Aus dem Pod heraus ist das ein Eigentor: Der DNS-Name löst auf den externen LoadBalancer des Clusters auf, und ein Pod, der seine eigene externe IP anspricht, läuft in einen NAT-Hairpin, den der Cluster nicht traversiert. Die Verbindung hängt einfach. Richtig ist die in-cluster Service-Adresse:

1- name: RAD_HUB_SEED
2  value: "z6MknxF8…@seed-node.radicle.svc.cluster.local:8776"

Externe Operatoren nutzen weiter seed.this-is-fine.io; nur Pods im selben Cluster müssen den internen Service nehmen. Derselbe Stolperstein gilt für den ZeroClaw-Bot — er steht inzwischen als eigene Zeile in meiner RADICLE.md-Troubleshooting-Tabelle.

Seeding-Scope: nur die Repos, die wirklich gebaut werden

Die ci-node seedet eine explizite Liste von RIDs — und nur die, deren CI laufen soll (die Image-Repos plus das Lab selbst), nicht die volle Liste der Seed-Node. Die enthält nämlich Upstreams wie heartwood, die gar keine .radicle/native.yaml haben und an denen sich ein Build nur verschluckt:

1- name: RAD_SEEDS
2  value: "rad:z6MQ7ck2rSh7h4qEgbRYg3ftnrGv;rad:zVi9Vhea…;rad:z43rrcn7…"

Die RIDs sind nicht geheim — sie stehen offen im Manifest. Das client-node-Image wendet die Liste beim Start an (apply_seeds). Welches genau dabei half, kommt im nächsten Punkt.

Der stale :latest-Tag, der mich zwei Mal erwischte

Die ci-node wollte partout nicht seeden. Logs sauber, Config korrekt, und trotzdem kam apply_seeds nie zum Zug. Der Grund war keiner im Cluster, sondern im Image: Ich zog radicle/node:latest, und der Registry-Mirror des Clusters servierte eine stale :latest — v0.0.9, ohne apply_seeds —, während die Registry selbst längst v0.0.10 hatte. imagePullPolicy: Always half nicht; der Mirror lieferte hartnäckig die alte Schicht aus.

Die Lösung ist unspektakulär und unverhandelbar: per Digest pinnen. Ein Digest ist immutable und geht am stale Tag-Cache vorbei:

1image: "oci.${EXTERNAL_DOMAIN}/radicle/node@sha256:4b6823cb…018a6ee2"
2imagePullPolicy: IfNotPresent

Derselbe Fehler erwischte mich später ein zweites Mal beim cib-Image — auch dort lieferte der Mirror trotz Always die alte Variante, diesmal ohne die ci-build/helm-Tools. Wieder Digest-Pin. Zwei verlorene Abende für dieselbe Lektion: In einem Cluster mit Registry-Mirror ist :latest eine Lüge, auf die man sich nicht verlassen darf.

Dieser doppelte Schmerz war übrigens der Auslöser, mir Spegel als P2P-Image-Mirror anzusehen — ein Cache, der Layer direkt aus dem containerd-Content-Store jedes Nodes verteilt. Das liegt vorbereitet im Repo (inklusive der nötigen Talos-Anpassung discard_unpacked_layers = false), ist aber bewusst noch nicht scharfgeschaltet. Ein Nebenschauplatz, kein Teil dieser Reise.

Der Trigger, der vom „falschen" Node kam

Selbst als Events flossen, ignorierte der Broker meinen manuellen Test-Trigger. Der cib-Filter ist AnyDelegate — gebaut wird nur, wenn das Event von einem Delegate des Repos stammt (also von mir). cibtool trigger setzt den from-node aber per Default auf die lokale ci-node — und die ist kein Delegate. Also fiel jeder manuelle Trigger durch den Filter, ohne Fehlermeldung, einfach lautlos verworfen.

Der Fix war, im Task-Wrapper den from-node explizit auf meine Delegate-NID zu defaulten:

1NODE="z6Mkti7M59YqVqGQWBek52LiFfsS1ANrynQrCdywBVeMxocc"   # ff0x, delegate
2cibtool --db /ci-broker/cib/cib.db trigger --repo "$REPO" --ref "${REF:-main}"

Das ist genau die Trust-Grenze aus dem Design-Post, nur von der anderen Seite erlebt: Der Filter, der Fremd-Code aussperrt, sperrt auch den eigenen Test aus, wenn er vom falschen Absender kommt.

Akt 3 — buildah in einem Container, ohne privileged

Das war die eigentliche Schlacht. Der native Adapter ruft pro Image-Repo dessen build.sh, und die baut Container-Images mit buildah . buildah in einem Kubernetes-Pod laufen zu lassen, ohne den Pod privileged zu machen, ist ein eigenes kleines Kapitel Linux-Wissen — das ich mir der Reihe nach erarbeiten musste.

Die rootless-Sackgasse

Der „richtige" Weg ist rootless buildah. Ich bin ihn gegangen, Schritt für Schritt, und gegen jede Tür gelaufen:

  1. buildah braucht unshare(CLONE_NEWUSER). Das Default-seccomp-Profil der Runtime blockt den Syscall mit EPERM. → seccompProfile: Unconfined. Das wiederum verbietet baseline/restricted PSA, also musste der radicle-Namespace auf privileged PSA (nicht: privileged Container).
  2. Es braucht den Sysctl user.max_user_namespaces auf der Node — den habe ich per Talos-Patch auf den Workern gesetzt (11255).
  3. Dann: „insufficient UIDs/GIDs". buildah schreibt die uid/gid-Map über die setuid-Helfer newuidmap/newgidmap — und die brauchen, dass no_new_privs aus ist. → allowPrivilegeEscalation: true.
  4. Immer noch „write to uidmap: Operation not permitted". Die Helfer brauchen CAP_SETUID + CAP_SETGID _effective. → capabilities: add: [SETUID, SETGID].

Und dann, nach all dem, immer noch dieselbe Wand. Der Grund ist fundamental und kein Tuning-Problem: Kubernetes gibt einem non-root-Prozess keine effektiven Capabilities. Ohne Ambient-Caps ist CapEff=0, der setuid-Helfer scheitert, die Map bleibt auf einem einzigen Eintrag — „insufficient UIDs/GIDs". Rootless buildah ist in einem stock-Kubernetes-Pod schlicht nicht erreichbar.

Der Schwenk: rootful, aber nicht privileged

Die Lösung war, das Problem umzudrehen. Statt rootless mit Namespace-Mapping fahre ich buildah rootful — als runAsUser: 0 — mit chroot-Isolation:

1securityContext:
2  runAsUser: 0
3  allowPrivilegeEscalation: true
4  seccompProfile:
5    type: Unconfined
6env:
7  - { name: BUILDAH_ISOLATION, value: chroot }

Als root braucht buildah kein User-Namespace und keine subuid-Map: Es chownt die Image-Layer direkt mit CAP_CHOWN und isoliert per chroot. Entscheidend — und der Punkt, auf den es mir ankam: Das ist nicht privileged. Kein CAP_SYS_ADMIN, kein privilegierter Container, kein Host-Mount. Es ist der von buildah dokumentierte Weg, „rootful in a container" zu bauen, und er fügt sich genau in das privileged-PSA-Fenster, das ich ohnehin für Unconfined brauchte.

Die Lektion in einem Satz: rootless in Kubernetes scheitert an CapEff=0 für non-root; rootful + chroot baut ohne ein einziges zusätzliches Capability über CAP_CHOWN hinaus — und ohne CAP_SYS_ADMIN. „root im Container" ist hier sicherer als der vermeintlich saubere rootless-Weg, weil es keine Privilege-Escalation-Helfer und kein Unconfined-seccomp-für-userns mehr braucht.

„runroot must be set" — die storage.conf-Odyssee

buildah lief, scheiterte aber sofort an runroot must be set. Die im Image gebackene /etc/containers/storage.conf deklariert nur den Treiber, nicht die Roots. Drei Dinge zusammen haben es gelöst:

  • Die XDG-Pfade auf die beschreibbare PVC legen — /run/user/<uid> existiert im Pod nicht: XDG_RUNTIME_DIR=/ci-broker/run, XDG_DATA_HOME=/ci-broker/.local/share.
  • Eine vollständige storage.conf (Treiber plus graphroot plus runroot) mitgeben.
  • Und der subtile Teil: Diese Datei sowohl als CONTAINERS_STORAGE_CONF referenzieren als auch an /etc/containers/storage.conf mounten. Denn der buildah-RUN/Runtime-Pfad liest die System-Datei, nicht die Env-Variable. Nur eins von beiden zu setzen reicht nicht — ich habe beide gebraucht.

vfs ist ehrlich, aber teuer — also overlay

Erst lief der Storage-Driver auf vfs. vfs ist robust und braucht keine Kernel-Tricks, kopiert aber jeden Layer in voller Größe. Bei mehreren Rust-Images frisst das Plattenplatz und Zeit ohne Ende. Im buildtest-Sandbox — ein nicht-Flux-verwaltetes Debug-Deployment mit exakt demselben Security-Profil wie cib — habe ich verifiziert, dass der overlay-Treiber als rootful-root mit chroot-Isolation ohne CAP_SYS_ADMIN funktioniert. overlay ist Copy-on-Write, dramatisch schneller und schlanker. Seitdem:

1[storage]
2driver = "overlay"
3graphroot = "/var/lib/buildah/storage"
4runroot = "/var/lib/buildah/run"

Die Roots liegen auf einer dedizierten 100-Gi-Scratch-PVC (ceph-block, nicht VolSync-gebackt — es ist Wegwerf-Speicher, kein State), getrennt von der State-PVC mit cib.db, RAD_HOME und den Reports. Sonst füllt ein großer Build die Datenbank-PVC und legt den Broker lahm.

„dubious ownership" — die letzte Kleinigkeit

Fast geschafft, dann git: detected dubious ownership. cib (libgit2) und native-ci (git) laufen als root, der Radicle-Store gehört aber der ci-node (uid 10001). git verweigert fremde Repo-Pfade. Da alle Checkouts read-only und delegate-verifiziert sind, ist die saubere Antwort:

1git config --system --add safe.directory '*' || true

Akt 4 — Cross-arch, und ein Kernel, der vergisst

Meine Image-Repos bauen multi-arch (arm64 + amd64). Mein Cluster ist arm64. amd64-RUN-Steps müssen also über QEMU emuliert werden — und das wurde der zähste Einzelkampf der ganzen Reise.

Die Ausgangslage: Die Talos-Extension siderolabs/binfmt-misc im Worker-Schematic aktiviert nur das Kernel-Feature binfmt_misc; sie registriert keine Emulatoren. Das übernimmt sonst ein Init-Container à la tonistiigi/binfmt. Genau der war das Problem:

  Init-Container registriert qemu-x86_64
        │  (in EIGENER mount-namespace)
        v
  ┌──────────────────────────┐
  │ binfmt_misc instance #N  │  <── Handler lebt hier
  └──────────────────────────┘
        │  Init-Container endet
        v
     mount-ns weg  ──>  instance #N freigegeben  ──>  Handler WEG
        v
  buildah exec amd64  ──>  "exec format error"

Auf Kernel 6.x ist binfmt_misc per mount-namespace und die Registrierung reference-counted an ihrem Mount. Ein Init-Container, der registriert und sich beendet, nimmt seinen Mount mit ins Grab — und damit den Handler. buildah, das in seiner eigenen mount-namespace nach dem qemu-Handler sucht, findet nichts: exec format error.

Ich habe den Zwischenschritt probiert, den Host-/proc/sys/fs/binfmt_misc per hostPath in den Init-Container zu bind-mounten, damit die Registrierung in der globalen Host-Instanz landet. Half nicht zuverlässig — dieselbe reference-counting-Mechanik schlägt zu, sobald der registrierende Prozess endet.

Die Lösung ist unelegant und empirisch verifiziert: ein langlebiger privilegierter DaemonSet, der den Mount hält, solange er läuft. Er installiert qemu-user-static + binfmt-support, mountet binfmt_misc, registriert qemu-x86_64 mit dem fix-binary-Flag (F) — und bleibt dann per sleep infinity am Leben, um den Mount offenzuhalten. Eine Liveness-Probe re-registriert, falls der Handler nach einem Node-Reboot verschwindet:

1apt-get install -y --no-install-recommends qemu-user-static binfmt-support
2mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc 2>/dev/null || true
3update-binfmts --enable qemu-x86_64
4echo "[binfmt] $(grep -o 'flags:.*' /proc/sys/fs/binfmt_misc/qemu-x86_64)"
5exec sleep infinity     # hält den Mount — und damit die Registrierung — am Leben

Das F-Flag ist der Grund, warum das überhaupt tragfähig ist: Mit fix-binary wird der qemu-Datei-Deskriptor zum Registrierungszeitpunkt gefangen, der Emulator muss danach nicht mehr auf dem Host liegen. Der DaemonSet lebt nur, um den Mount zu halten.

apt-get install qemu-user-static entpackt ~150 MB Emulatoren und spitzt während dpkg im Speicher. Mein Memory-Limit war erst 128 Mi, dann 512 Mi — beide hat der OOM-Killer geholt (exit 137), und der DaemonSet ging in CrashLoop, bevor er je registrierte. Erst 1Gi Limit (transient, der Node hat 16 Gi) ließ ihn durchlaufen. Ein klassischer Fall, in dem das eigentliche Problem (kein binfmt-Handler) eine ganz andere Ursache (OOM beim Setup) hatte.

Und am Ende eine pragmatische Entscheidung: Trotz funktionierendem Handler baue ich vorerst arm64-only. Der cib-Container setzt ARCHES=arm64, was jede build.sh über ${ARCHES:-arm64 amd64} liest — ein Knopf, alle Builds einarmig. Die amd64-Emulation über den Host-Kernel war mir noch nicht stabil genug für den scharfen Pfad; die Cross-arch-Tür steht offen, aber ich gehe sie bewusst erst, wenn amd64 sauber cross-kompiliert statt emuliert wird. Lieber ein verlässlicher arm64-Build als ein flakiger multi-arch.

Was am Ende baut

Nach all dem steht der Loop, den der Design-Post versprochen hatte — nur dass ich jetzt jede Schraube daran kenne:

  git push rad HEAD:refs/patches
        v
  seed-node (:8776) ──> ci-node (RefsFetched) ──> cib filtert
                                                   │ AnyDelegate + Branch main
                                                   v
                                      native adapter: checkout
              ┌────────────────────────────────────┴───────────────────┐
              │ Lab-Repo:    validate.sh > kustomize + kubeconform     │
              │ Image-Repos: build.sh > buildah (rootful/overlay)      │
              │              > cosign sign > Zot (oci.<domain>)        │
              │                                                        │
              └───────────┬───────────────────────────────┬────────────┘
                          │                               │
                          v                               v
                reports > caddy                 Kyverno verify @
                (ci.<domain>)                   Admission ──> Pod

Zwei Dinge sind dabei wichtig zu trennen. Das Lab-Repo selbst ist kein Image — seine .radicle/native.yaml ruft nur validate.sh, das jede kustomize-Overlay rendert und mit kubeconform prüft (inklusive der Gateway-API-Versions-Pin-Kontrolle). Ein kaputtes Manifest fällt damit in der CI auf, bevor es als Flux-Reconcile-Fehler auf hydra landet. Die Image-Repos dagegen fahren den vollen Pfad — buildah → cosign-sign → Push nach Zot —, und Kyverno verifiziert die Signatur beim Admission. Vom git push rad bis zum laufenden Pod liegt jeder Schritt auf eigener Infrastruktur.

Und der Trust-Filter, über den ich im Design-Post noch theoretisch geschrieben habe, ist jetzt scharf — und er ist die einzige Sicherheitsgrenze, weil der native Adapter im Broker-Container selbst baut:

1triggers:
2  - adapter: native
3    filters:
4      - !And
5        - !AnyDelegate # nur Code von einem Repo-Delegate (= ich)
6
7        - !Branch main # nur der kanonische Branch

Da der Build kein VM-Sandbox-Layer hat, muss dieser Filter halten: AnyDelegate vergleicht den Event-Ursprung gegen die Delegate-Menge des Repos, Branch main schränkt auf die kanonische Linie ein. Fremd-NIDs — auch die eigene ci-node, auch der ZeroClaw-Bot — lösen keinen automatischen Lauf aus. Das ist der Türsteher, den eine zentrale Forge einem abnimmt und den eine P2P-Forge zurückgibt. Im selbstgebauten Broker ist er eine Handvoll YAML — aber eine, die man verstanden haben muss.

Bedienung, damit man den Schmerz nicht wiederholt

Die letzte Charge fix:-Commits drehte sich um Observability — weil „warum baut mein Patch nicht" ohne Werkzeug die Hölle ist. Drei Tasks decken das ab:

  • task radicle:ci-logs — der Broker-Steuerlog (cib-Container), per jq zu HH:MM:SS LEVL message verdichtet. Läuft default auf --log-level warning, also ruhig; für „warum lief mein Build nicht" temporär auf info hochdrehen, dann sieht man die Filter-Entscheidung.
  • task radicle:ci-watch — der Live-Build-Log. native-ci schreibt das per-run log.html erst am Ende; um den laufenden Build zu sehen, teet jede build.sh ihre Ausgabe nach CI_LIVE_LOG, und caddy serviert sie unter ci.<domain>/live.log.
  • task radicle:ci-runs — die Run-Historie aus cibs SQLite-DB via cibtool run list.

Das ist die Sorte Tooling, die man erst baut, wenn man sie schmerzhaft vermisst hat — und dann nie wieder hergibt.

Was ich mitnehme

Der Reisebericht ist nüchtern: Zwischen einem sauberen Architektur-Diagramm und einem grünen Häkchen lag fast jede Schicht des Stacks. Ein one-shot-Broker, der eine Warteschleife brauchte. Flux, das meine Shell-Variablen auffraß. Ein Registry-Mirror, der :latest zwei Mal verriet. Rootless buildah, das in Kubernetes an einem CapEff=0 scheitert, das kein Tutorial erwähnt. Und ein Kernel 6.x, der meinen QEMU-Handler reference-counted weglöscht, sobald ein Init-Container endet.

Keiner dieser Stolpersteine stand in einem Changelog. Jeder kostete einen Abend — und jeder steht jetzt als Kommentar direkt am Manifest, damit der nächste Mensch (oder ich in sechs Monaten) ihn nicht zwei Mal trifft. Was bleibt, ist genau das, was ich wollte: vom git push rad HEAD:refs/patches bis zum signierten Image in der eigenen Registry, ohne eine einzige zentrale Forge dazwischen. Der eine bewusst offene Punkt ist amd64 — Emulation tausche ich gegen echte Cross-Kompilierung, wenn ruhigere Tage kommen. Spegel wartet im selben Karton.


Das Lab liegt offen auf meinem Radicle -Seed seed.this-is-fine.io — Broker-Manifeste, der buildtest-Sandbox, der binfmt-DaemonSet und die ganze fix:-Historie inklusive. Jeder Stolperstein dieser Reise ist dort als Commit-Kommentar dokumentiert, nicht als nachträglich geglättete Erzählung. So fühlt sich Self-Hosting wirklich an: nicht das Diagramm, sondern die zwei Dutzend Wände dahinter.