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
This commit is contained in:
Thore Cimbal
2026-08-09 12:00:00 +00:00
parent 0fde69a6f2
commit f6d843672b
4 changed files with 359 additions and 0 deletions
+35
View File
@@ -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
+24
View File
@@ -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-----
+236
View File
@@ -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())
+64
View File
@@ -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.