From f6d843672b4a5459e91c54c92ffcd59ef24b284f Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Sun, 9 Aug 2026 12:00:00 +0000 Subject: [PATCH] feat: add the staleness check decided in the retro Looks for things that quietly stopped working. Every check mirrors a case that actually happened this month, named in its docstring: a repo that was never mirrored, mirror drift, pipelines with zero jobs, a successful job without artifacts, an npm package too small to contain a build, and Authentik blueprints stuck on error. Nothing is checked on suspicion. The project list is read from the group at runtime rather than maintained in code - a hardcoded list is exactly where a new repo slips through for years. The first run proved the point by surfacing two projects nobody had in mind. It aborts when a credential is missing instead of skipping quietly. A check that disables itself reports nothing for years and is indistinguishable from all clear. Refs axion1337.chat/management#28 --- .gitlab-ci.yml | 35 +++++ ci/lab-ca-chain.crt | 24 ++++ scripts/stillstandspruefung.py | 236 +++++++++++++++++++++++++++++++ verfahren/stillstandspruefung.md | 64 +++++++++ 4 files changed, 359 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100644 ci/lab-ca-chain.crt create mode 100755 scripts/stillstandspruefung.py create mode 100644 verfahren/stillstandspruefung.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..06c7d4e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,35 @@ +# CI des management-Repos. Enthaelt bewusst nur die Stillstandspruefung — hier +# liegt Dokumentation, es gibt nichts zu bauen. +# +# Die Pruefung sucht Dinge, die leise aufgehoert haben zu funktionieren. Anlass ist +# die Retro vom 2026-08-09: sechs solcher Faelle in neun Tagen, keiner davon durch +# eine Ueberwachung gefunden. + +stages: + - pruefen + +# Nur geplant und von Hand. Bei jedem Doku-Commit zu laufen brächte nichts — +# die Pruefung sieht auf den Zustand des Verbunds, nicht auf diesen Commit. +stillstandspruefung: + stage: pruefen + image: python:3.12-alpine + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + - if: $CI_PIPELINE_SOURCE == "web" + variables: + LAB_CA: "$CI_PROJECT_DIR/ci/lab-ca-chain.crt" + before_script: + - apk add --no-cache curl ca-certificates >/dev/null + script: + # Ohne Token laeuft nichts — und das soll laut auffallen. Eine Pruefung, die + # sich still selbst ueberspringt, waere genau der Fehler, den sie sucht. + - | + if [ -z "$GITLAB_TOKEN" ]; then + echo "GITLAB_TOKEN fehlt (Gruppen-Token mit read_api)." + echo "Ohne Lesezugriff kann nichts geprueft werden - siehe management#31." + exit 1 + fi + - python3 scripts/stillstandspruefung.py + # Befunde sind kein Betriebsausfall, aber sie sollen sichtbar bleiben. Die rote + # Pipeline ist bei uns die Alarmanlage (gitops/CLAUDE.md, TURN-Rotation). + allow_failure: false diff --git a/ci/lab-ca-chain.crt b/ci/lab-ca-chain.crt new file mode 100644 index 0000000..6e9a407 --- /dev/null +++ b/ci/lab-ca-chain.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIBrzCCAVWgAwIBAgIQMZ1JRdPpesdAzJqMZOa9tDAKBggqhkjOPQQDAjA2MRUw +EwYDVQQKEwxhWGlvbkxhYnMgQ0ExHTAbBgNVBAMTFGFYaW9uTGFicyBDQSBSb290 +IENBMB4XDTI1MTIwMjIyMDEyMVoXDTM1MTEzMDIyMDEyMVowNjEVMBMGA1UEChMM +YVhpb25MYWJzIENBMR0wGwYDVQQDExRhWGlvbkxhYnMgQ0EgUm9vdCBDQTBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABFuhwykAsSg2BWI3wqWHjNuL+5xlGjhHyiSt +6YmoSd1tJGa7WA5pdRRm2JZriA3IWuhdkZVTUl8nLduRJVTb5KSjRTBDMA4GA1Ud +DwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBRGPIQeqG1S +CyHYLwSQbe4jQ9RcZDAKBggqhkjOPQQDAgNIADBFAiEA1Z0Dxtj9hJOYkLJ8FLNT +96eatVP/OsKyaRk/MxReIwMCICrCeNSSOGUbiZ8Hd89jtlkCpWiLH1pCzZPUtYDX +R7gr +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIB2DCCAX6gAwIBAgIQLv6q8XwRjaYo8l0gB9NdyjAKBggqhkjOPQQDAjA2MRUw +EwYDVQQKEwxhWGlvbkxhYnMgQ0ExHTAbBgNVBAMTFGFYaW9uTGFicyBDQSBSb290 +IENBMB4XDTI1MTIwMjIyMDEyMloXDTM1MTEzMDIyMDEyMlowPjEVMBMGA1UEChMM +YVhpb25MYWJzIENBMSUwIwYDVQQDExxhWGlvbkxhYnMgQ0EgSW50ZXJtZWRpYXRl +IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECj764yT9QNMsRjEfmHnuDI1o +11faTeMl5kcwZlnDetHF5O+Ey5d+kKuysqIOF1YX8bPt6o61aJYRmc4RxRDU/aNm +MGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYE +FGpRTHbW0eTgBMJep8w+Qyvy9oWzMB8GA1UdIwQYMBaAFEY8hB6obVILIdgvBJBt +7iND1FxkMAoGCCqGSM49BAMCA0gAMEUCIEAbqm/Y14dyM7h+I5en2l596eDDh3cS +2GwVO3CKuBY2AiEAkjPO/8M8PaFEaoncc/JDFxk9KR2P9IY24BvUA7YVsAU= +-----END CERTIFICATE----- diff --git a/scripts/stillstandspruefung.py b/scripts/stillstandspruefung.py new file mode 100755 index 0000000..1f7cb55 --- /dev/null +++ b/scripts/stillstandspruefung.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Stillstandspruefung — sucht Dinge, die leise aufgehoert haben zu funktionieren. + +Hintergrund: Retro 2026-08-09. In neun Tagen sind sechs Fehler aufgefallen, die +alle dasselbe Merkmal hatten — sie sahen erfolgreich aus, ohne es zu sein. Keiner +wurde durch eine Ueberwachung gefunden, vier durch Zufall beim Suchen nach etwas +anderem. + +Jede Pruefung hier bildet einen konkreten, real passierten Fall ab. Neue Pruefungen +kommen dazu, wenn wieder etwas still ausfaellt — nicht auf Vorrat. + +Beendet sich mit 1, wenn etwas gefunden wurde. In der CI heisst das: rote Pipeline, +und die ist bei uns die Alarmanlage (siehe gitops/CLAUDE.md zur TURN-Rotation). + +Braucht: + GITLAB_TOKEN Lesezugriff auf die Gruppe axion1337.chat (read_api) + LAB_CA optional, Pfad zur Lab-CA-Kette + AUTHENTIK_URL optional, sonst wird die Blueprint-Pruefung uebersprungen + AUTHENTIK_TOKEN +""" + +import json +import os +import subprocess +import sys +import urllib.parse +from datetime import datetime, timedelta, timezone + +GITLAB = "https://git.lab/api/v4" +GITEA = "https://rohana.axion1337.de/api/v1" +GROUP = "axion1337.chat" + +# Wird zur Laufzeit aus der Gruppe gelesen statt gepflegt — eine Liste im Code +# waere genau die Stelle, an der ein neues Repo jahrelang durchrutscht. +PROJEKTE = [] + +befunde = [] +uebersprungen = [] +MIT_MIRROR = [] + + +def http(url, token=None, header="PRIVATE-TOKEN"): + cmd = ["curl", "-s", "--max-time", "25"] + if token: + cmd += ["-H", f"{header}: {token}"] + ca = os.environ.get("LAB_CA") + if ca and "git.lab" in url: + cmd += ["--cacert", ca] + cmd.append(url) + out = subprocess.run(cmd, capture_output=True, text=True).stdout + try: + return json.loads(out) + except json.JSONDecodeError: + return None + + +def gl(path, token): + return http(f"{GITLAB}{path}", token) + + +def enc(project): + return urllib.parse.quote(f"{GROUP}/{project}", safe="") + + +def lade_projekte(token): + d = gl(f"/groups/{urllib.parse.quote(GROUP, safe='')}/projects?per_page=100&archived=false", token) + if not isinstance(d, list): + return [] + return sorted(p["path"] for p in d) + + +def pruefe_mirror_vorhanden(token): + """Real passiert 2026-08-09: game-operating wurde in einer Session angelegt und + nie gespiegelt — auf Gitea existierte es gar nicht. Die Repo-Topologie sagt, + dass die Gruppe gespiegelt wird; ein Repo ohne Mirror widerspricht ihr still.""" + for p in PROJEKTE: + m = gl(f"/projects/{enc(p)}/remote_mirrors", token) + if not isinstance(m, list): + uebersprungen.append(f"Mirror-Konfiguration {p}: nicht lesbar") + continue + aktiv = [x for x in m if x.get("enabled")] + if aktiv: + MIT_MIRROR.append(p) + else: + befunde.append( + f"{p}: kein aktiver Push-Mirror. Laut Repo-Topologie wird die Gruppe " + f"gespiegelt — entweder nachziehen oder die Ausnahme begruenden.") + for x in aktiv: + if x.get("last_error"): + befunde.append(f"{p}: Push-Mirror meldet Fehler: {str(x['last_error'])[:120]}") + + +def pruefe_mirror(token, gespiegelt): + """Real passiert: nie — aber MIRROR-01 (management#28) beschreibt genau das. + Faellt ein Push-Mirror aus, liefert Flux weiter den letzten gespiegelten Stand + aus und alles sieht gesund aus. + + Nur fuer Repos MIT konfiguriertem Mirror. Ohne Mirror sind zwei gleichnamige + Repos schlicht zwei verschiedene Repos — sie zu vergleichen erzeugt einen + Befund, der nach Drift aussieht und keiner ist.""" + for p in gespiegelt: + lab = gl(f"/projects/{enc(p)}/repository/commits?ref_name=main&per_page=1", token) + if not isinstance(lab, list) or not lab: + uebersprungen.append(f"Mirror {p}: git.lab nicht lesbar") + continue + git = http(f"{GITEA}/repos/sorb/{p}/commits?limit=1") + if not isinstance(git, list) or not git: + uebersprungen.append(f"Mirror {p}: auf Gitea nicht lesbar") + continue + if lab[0]["id"] != git[0]["sha"]: + befunde.append( + f"Mirror {p} auseinander: git.lab {lab[0]['id'][:8]} / " + f"gitea {git[0]['sha'][:8]}. Flux liest die Gitea-Seite.") + + +def pruefe_leere_pipelines(token): + """Real passiert 2026-08-06 (ThreadNet-Web 203/204) und bei threadnet-call 187: + Pipelines ohne einen einzigen Job gelten als failed. Rot, das nichts bedeutet, + gewoehnt einem das Hinsehen ab.""" + seit = (datetime.now(timezone.utc) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ") + for p in PROJEKTE: + pls = gl(f"/projects/{enc(p)}/pipelines?status=failed&updated_after={seit}&per_page=20", token) + if not isinstance(pls, list): + continue + for pl in pls: + jobs = gl(f"/projects/{enc(p)}/pipelines/{pl['id']}/jobs?per_page=1", token) + if isinstance(jobs, list) and len(jobs) == 0: + befunde.append( + f"{p}: Pipeline {pl['id']} ({pl['ref']}) ist rot, hat aber NULL Jobs — " + f"kein Fehler, sondern eine Regel, die keine Jobs erzeugt.") + + +def pruefe_artefakte(token): + """Real passiert bei threadnet-call: build_embedded lief seit jeher gruen und + lud NICHTS hoch, weil der artifacts-Pfad nicht zum Build-Output passte. Der + nachgelagerte publish_npm haette ein leeres Paket veroeffentlicht.""" + seit = (datetime.now(timezone.utc) - timedelta(days=8)).strftime("%Y-%m-%dT%H:%M:%SZ") + for p in PROJEKTE: + jobs = gl(f"/projects/{enc(p)}/jobs?scope[]=success&per_page=40", token) + if not isinstance(jobs, list): + continue + for j in jobs: + if (j.get("finished_at") or "") < seit: + continue + # Nur Jobs, von denen andere per needs Artefakte erwarten koennen + if not j.get("artifacts") and j["name"] in ("build_embedded", "web"): + befunde.append( + f"{p}: Job '{j['name']}' (#{j['id']}) war erfolgreich, hat aber " + f"kein Artefakt — nachgelagerte Jobs bekommen nichts.") + + +def pruefe_npm_paketgroessen(): + """Real passiert 2026-08-06: 0.19.2-threadnet.6 lag mit 12,5 KB statt 12,8 MB + in der Registry — package.json und Lizenzen, kein dist/. Eine npm-Version laesst + sich nicht ueberschreiben, die Nummer war dauerhaft verbrannt.""" + meta = http("https://rohana.axion1337.de/api/packages/sorb/npm/" + "@sorb%2Fthreadnet-call-embedded") + if not isinstance(meta, dict) or "versions" not in meta: + uebersprungen.append("npm-Paket: Registry nicht lesbar") + return + MINDEST = 1_000_000 # ein Widget-Bundle liegt bei ~12 MB + for ver, v in meta["versions"].items(): + url = v.get("dist", {}).get("tarball") + if not url: + continue + out = subprocess.run( + ["curl", "-sIL", "--max-time", "25", "-o", "/dev/null", + "-w", "%{size_download} %{header_json}", url], + capture_output=True, text=True).stdout + try: + size = int(json.loads(out.split(" ", 1)[1])["content-length"][0]) + except Exception: + continue + if size < MINDEST: + befunde.append( + f"npm @sorb/threadnet-call-embedded@{ver}: nur {size:,} Bytes — " + f"das Paket enthaelt vermutlich kein dist/.") + + +def pruefe_authentik_blueprints(): + """Real passiert: matrix-recovery-flow wurde tagelang bei JEDEM Lauf verworfen, + waehrend Flux gruen war und die ConfigMap aktuell. Gefunden nur, weil jemand + fuer etwas anderes in die Datenbank sah.""" + url, tok = os.environ.get("AUTHENTIK_URL"), os.environ.get("AUTHENTIK_TOKEN") + if not (url and tok): + uebersprungen.append( + "Authentik-Blueprints: AUTHENTIK_URL/AUTHENTIK_TOKEN fehlen — " + "genau der Fall, der uns am laengsten unbemerkt lief") + return + d = http(f"{url.rstrip('/')}/api/v3/managed/blueprints/?page_size=100", + tok, header="Authorization: Bearer") + if not isinstance(d, dict): + uebersprungen.append("Authentik-Blueprints: API nicht lesbar") + return + for b in d.get("results", []): + if b.get("status") != "successful": + befunde.append( + f"Authentik-Blueprint '{b.get('name')}': Status " + f"'{b.get('status')}' — wird bei jedem Lauf verworfen.") + + +def main(): + token = os.environ.get("GITLAB_TOKEN") + if not token: + print("GITLAB_TOKEN fehlt — ohne Lesezugriff ist keine Pruefung moeglich.") + return 2 + global PROJEKTE + PROJEKTE = lade_projekte(token) + if not PROJEKTE: + print("Gruppe nicht lesbar — Token pruefen.") + return 2 + print(f"Geprueft: {len(PROJEKTE)} Projekte der Gruppe {GROUP}\n") + pruefe_mirror_vorhanden(token) + pruefe_mirror(token, MIT_MIRROR) + pruefe_leere_pipelines(token) + pruefe_artefakte(token) + pruefe_npm_paketgroessen() + pruefe_authentik_blueprints() + + if uebersprungen: + print("Uebersprungen:") + for u in uebersprungen: + print(f" - {u}") + print() + if befunde: + print(f"BEFUNDE ({len(befunde)}):") + for b in befunde: + print(f" ! {b}") + print("\nJeder Befund gehoert als Issue erfasst, nicht weggeklickt.") + return 1 + print("Keine Befunde.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verfahren/stillstandspruefung.md b/verfahren/stillstandspruefung.md new file mode 100644 index 0000000..ad30421 --- /dev/null +++ b/verfahren/stillstandspruefung.md @@ -0,0 +1,64 @@ +# Stillstandsprüfung + +Sucht Dinge, die **leise aufgehört haben zu funktionieren**. Beschlossen in der +[Retro 2026-08-09](retro/2026-08-09.md). + +## Warum es sie gibt + +In neun Augusttagen sind sechs Fehler aufgefallen, die alle dasselbe Merkmal +hatten: Sie sahen erfolgreich aus, ohne es zu sein — eine grüne Pipeline, die nie +ein Artefakt hochlud; ein veröffentlichtes npm-Paket ohne Inhalt; ein Blueprint, +der bei jedem Lauf verworfen wurde, während Flux grün meldete. + +**Keiner davon wurde durch eine Überwachung gefunden.** Vier durch Zufall beim +Suchen nach etwas anderem. Genau diese Lücke schließt das Skript. + +## Was geprüft wird + +Jede Prüfung bildet einen **real passierten** Fall ab. Nichts steht hier auf +Vorrat. + +| Prüfung | Der Fall dahinter | +|---|---| +| Repo ohne aktiven Push-Mirror | `game-operating` wurde angelegt und nie gespiegelt — auf Gitea existierte es nicht | +| Mirror-Drift | MIRROR-01 (management#28): fällt der Mirror aus, liefert Flux still den letzten Stand weiter | +| Pipeline mit null Jobs | ThreadNet-Web 203/204, threadnet-call 187 — rot, ohne dass etwas kaputt war | +| Erfolgreicher Job ohne Artefakt | `build_embedded` lief seit jeher grün und lud **nichts** hoch | +| npm-Paket zu klein | `0.19.2-threadnet.6`: 12,5 KB statt 12,8 MB, ohne `dist/` | +| Authentik-Blueprint ≠ successful | `matrix-recovery-flow` wurde tagelang bei jedem Lauf verworfen | + +Die Projektliste wird **zur Laufzeit aus der Gruppe gelesen**, nicht im Code +gepflegt — eine Liste im Quelltext wäre genau die Stelle, an der ein neues Repo +jahrelang durchrutscht. (Beim ersten Lauf kamen so zwei Projekte zum Vorschein, +die niemand auf dem Schirm hatte.) + +## Wie sie läuft + +Geplanter CI-Job im management-Repo, zusätzlich von Hand über *Run pipeline* +auslösbar. Befunde färben die Pipeline **rot** — das ist bei uns die Alarmanlage, +nicht ein zusätzlicher Meldeweg (siehe `gitops/CLAUDE.md` zur TURN-Rotation). + +Lokal: + +```bash +export GITLAB_TOKEN=$(cat ~/.config/gitlab-lab/token) +export LAB_CA=.../ci/lab-ca-chain.crt +python3 scripts/stillstandspruefung.py +``` + +## Zwei Regeln für diese Prüfung + +**Ein Befund wird zum Issue, nicht weggeklickt.** Sonst wird die Prüfung zu dem, +was sie sucht: etwas, das läuft, ohne dass jemand hinsieht. + +⚠️ **Fehlt ein Zugang, bricht sie ab — sie überspringt sich nicht still.** Eine +Prüfung, die sich bei fehlendem Token selbst deaktiviert, ist wertlos: Sie meldet +dann jahrelang nichts, und niemand merkt den Unterschied zu „alles in Ordnung". +Ausnahme sind die klar benannten optionalen Teile (Authentik), die ihr Fehlen im +Ergebnis ausweisen. + +## Erweitern + +Neue Prüfungen kommen dazu, **wenn wieder etwas still ausgefallen ist** — mit einem +Docstring, der den konkreten Fall nennt. Prüfungen auf Verdacht erzeugen Rauschen +und kosten die Glaubwürdigkeit, die diese hier braucht.