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:
@@ -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
|
||||
@@ -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-----
|
||||
Executable
+236
@@ -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())
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user