#!/usr/bin/env bash # # dc-testkit - Werkzeugkasten fuer Agenten auf der Linux-Testumgebung # # Zweck: Ein Agent, der auf der geteilten Test-VM arbeitet, soll ohne Rueckfrage # an einen Menschen einen Testlauf durchfuehren koennen - Lizenz besorgen, Paket # installieren, Update einspielen, Ergebnis melden. Bis hierher brauchte er fuer # jeden dieser Schritte jemanden, der im WebUI etwas anklickt. # # Warum ein Skript und nicht "der Agent ruft die API direkt auf": Weil sonst # jeder Agent seine eigene Fassung der immer gleichen fuenf curl-Aufrufe baut, # jede mit anderen Vorgaben, anderer Fehlerbehandlung und anderem Umgang mit # dem Token. Die Unterschiede faellt niemandem auf, solange nichts schiefgeht - # und wenn etwas schiefgeht, ist unklar, ob die Anwendung oder der Aufruf schuld # war. Ein Weg, den alle nehmen, ist der einzige, dessen Fehler man findet. # # Abhaengigkeiten: curl und python3. Beides ist auf der VM vorhanden; jq wird # bewusst NICHT vorausgesetzt. Inzwischen ist es dort zwar installiert - aber # das Skript soll auf einer frisch aufgesetzten Maschine sofort laufen, ohne # dass jemand sie erst fuer alle veraendert. Was vorausgesetzt wird, muss # ueberall gelten, nicht nur auf der einen Maschine, die man vor sich hat. # # Vollstaendige Anleitung: docs/TESTUMGEBUNG.md im Deploymentcenter-Repo. set -euo pipefail # Fassung dieses Skripts. MUSS bei jeder Aenderung hochgezaehlt werden. # # Warum das eine Regel und keine Empfehlung ist: Das Skript liegt auf jeder # Testmaschine als Kopie. Bleibt die Zahl stehen, meldet eine Maschine mit dem # Stand von vorletzter Woche dieselbe Fassung wie eine frisch bezogene - und # niemand kann erkennen, warum derselbe Aufruf hier scheitert und dort nicht. # Genau so ist es passiert: doctor meldete auf einer Maschine "Bereit", waehrend # die ausgelieferte Fassung die fehlenden Rechte des Tokens laengst angemeckert # haette. Dieselbe Falle, in die vorher die Serverfassung getappt ist (2.0.0 in # der Konfiguration, waehrend 2.7.0 lief). DC_TESTKIT_VERSION="1.1.0" # --------------------------------------------------------------------------- # Konfiguration # # Reihenfolge: Schalter auf der Kommandozeile schlaegt Umgebungsvariable # schlaegt Konfigurationsdatei schlaegt Vorgabe. Die Datei ist der Ort fuer # alles, was auf dieser Maschine dauerhaft gilt - insbesondere das Token, das # damit nicht in der Shell-History landet. # --------------------------------------------------------------------------- CONFIG_FILE="${DC_TESTKIT_CONFIG:-$HOME/.config/dc-testkit/config}" # Die Datei wird mit "." gelesen, ihre Zeilen sind also gewoehnliche # Zuweisungen - und die ueberschrieben, was in der Umgebung stand. Damit war # die Rangfolge oben genau verkehrt herum: "DC_PROJECT=x dc-testkit report" # meldete stillschweigend in das Projekt aus der Datei. Auf einer Maschine mit # mehreren Projekten ist das die teuerste Art von Fehler - es geht nichts # kaputt, es landet nur alles am falschen Ort, und niemand sieht es. # # Deshalb: vorher merken, was in der Umgebung stand, hinterher wieder # durchsetzen. Leere Werte zaehlen als "nicht gesetzt" - ein "DC_TOKEN=" in # der Umgebung soll das Token aus der Datei nicht loeschen. DC_KONFIGURIERBAR='DC_URL DC_TOKEN DC_PROJECT DC_CHANNEL DC_INSTANCE DC_DISPLAY DC_PROJECT_DIR DC_MIN_SERVER DC_TESTKIT_URL DC_TESTKIT_STATE' for _dc_name in $DC_KONFIGURIERBAR; do printf -v "_dcenv_$_dc_name" '%s' "${!_dc_name-}" done if [ -f "$CONFIG_FILE" ]; then # shellcheck disable=SC1090 . "$CONFIG_FILE" fi for _dc_name in $DC_KONFIGURIERBAR; do _dc_gemerkt="_dcenv_$_dc_name" if [ -n "${!_dc_gemerkt-}" ]; then printf -v "$_dc_name" '%s' "${!_dc_gemerkt}" fi done unset _dc_name _dc_gemerkt DC_URL="${DC_URL:-https://dc.mhdf.de}" DC_URL="${DC_URL%/}" DC_PROJECT="${DC_PROJECT:-}" DC_TOKEN="${DC_TOKEN:-}" DC_CHANNEL="${DC_CHANNEL:-prod}" # Aelteste Fassung des Deploymentcenters, mit der dieses Testkit vollstaendig # arbeiten kann. Darunter fehlen /api/license/v1/dev-key (dc-testkit license), # der Bezug ueber /testkit/ und /api/tokens/v1/whoami (Rechtepruefung in # doctor). Die Zahl stand vorher nur in einer Fehlermeldung mitten im Ablauf - # doctor kannte sie nicht und meldete "Bereit" auf einem zu alten Server. DC_MIN_SERVER="${DC_MIN_SERVER:-2.6.1}" # Bezugsquelle dieses Skripts. Daraus zieht doctor die ausgelieferte Fassung # und selfupdate den neuen Stand. Die Adresse haengt am DC_URL der Instanz: # Wer gegen die Testinstanz arbeitet, soll auch deren Testkit bekommen. DC_TESTKIT_URL="${DC_TESTKIT_URL:-$DC_URL/testkit/dc-testkit.sh}" # Die Maschinenkennung entscheidet, welchen DEV-Schluessel der Server # heraussucht. Sie muss ueber Laeufe hinweg gleich bleiben - sonst sammelt sich # je Lauf ein weiterer Schluessel an, bis die Grenze je Projekt greift. DC_INSTANCE="${DC_INSTANCE:-$(hostname -s 2>/dev/null || hostname)}" # Virtuelles Display dieses Projekts, fest vergeben (TESTUMGEBUNG.md 5.5). # Ohne Vorgabe: keines - dann bleibt die Oberflaechenpruefung ausgeschaltet, # statt sich eine Nummer zu nehmen. Genau das macht "xvfb-run -a", und es # beginnt bei :99, also auf der Nummer eines anderen Projekts. DC_DISPLAY="${DC_DISPLAY:-}" # Verzeichnis des Projekt-Repos auf dieser Maschine. Ist es gesetzt und steht # kein DC_TOKEN fest, holt sich das Testkit das Werkbank-Token von dort - aus # der .mcp.json, in der es fuer den MCP-Adapter ohnehin schon liegt. # # Das ist der Ablageort, den die Projekte bereits benutzen: eine Datei je # Projekt, per .gitignore ausgeschlossen, mit dem Token in env.DC_TOKEN. Es ein # zweites Mal in ~/.config/dc-testkit zu hinterlegen hiesse, dasselbe Geheimnis # an zwei Orten zu pflegen - und beim naechsten Wechsel einen davon zu # vergessen. Genau das war auf der Test-VM passiert: In der Testkit-Konfiguration # lag noch ein altes Laufzeit-Token, waehrend das richtige Werkbank-Token im # Projekt langst ein anderes war. DC_PROJECT_DIR="${DC_PROJECT_DIR:-}" STATE_ROOT="${DC_TESTKIT_STATE:-$HOME/.local/state/dc-testkit}" # --------------------------------------------------------------------------- # Ausgabe # --------------------------------------------------------------------------- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m' C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m' else C_RESET=''; C_DIM=''; C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE='' fi note() { printf '%s\n' "$*"; } step() { printf '%s==>%s %s\n' "$C_BLUE" "$C_RESET" "$*"; } ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } warn() { printf '%swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } # Wie warn, aber auf stdout. Fuer doctor: Sein Bericht ist das Ergebnis des # Befehls, kein Fehlerkanal - das Ergebnis traegt der Rueckgabewert. Ueber SSH # werden stdout und stderr getrennt uebertragen und beim Aufrufer verschraenkt; # die Befunde standen dann unter der falschen Ueberschrift. Genau so liest ein # Pruefagent den Bericht - und ordnet die Zeile dem falschen Schritt zu. befund() { printf '%swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*"; } dim() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_RESET"; } die() { printf '%sFehler%s %s\n' "$C_RED" "$C_RESET" "$*" >&2 exit 1 } # --------------------------------------------------------------------------- # JSON # # python3 statt jq: siehe Kopf. Der Pfad wird mit Punkten angegeben, # Listenindizes in eckigen Klammern - json_get '.latest_release.version'. # Fehlt der Wert, kommt eine leere Zeile und Rueckgabewert 1; das laesst sich # mit || unterscheiden von "Wert ist leer". # --------------------------------------------------------------------------- json_get() { local path="$1" python3 -c ' import json, re, sys path = sys.argv[1].lstrip(".") try: data = json.load(sys.stdin) except Exception: sys.exit(2) cur = data if path: for part in re.findall(r"[^.\[\]]+", path): try: cur = cur[int(part)] if part.isdigit() and isinstance(cur, list) else cur[part] except (KeyError, IndexError, TypeError): sys.exit(1) if cur is None: sys.exit(1) if isinstance(cur, bool): print("true" if cur else "false") elif isinstance(cur, (dict, list)): print(json.dumps(cur, ensure_ascii=False)) else: print(cur) ' "$path" } # Baut ein JSON-Objekt aus Schluessel-Wert-Paaren. Ohne das muesste jeder # Aufruf sein JSON von Hand zusammensetzen - und spaetestens bei einem # Stacktrace mit Anfuehrungszeichen und Zeilenumbruechen geht das schief. json_object() { python3 -c ' import json, sys args = sys.argv[1:] out = {} for i in range(0, len(args) - 1, 2): key, value = args[i], args[i + 1] if value == "": continue if value in ("true", "false"): out[key] = value == "true" elif key in ("line_no", "limit", "ttl_days") and value.lstrip("-").isdigit(): out[key] = int(value) else: out[key] = value print(json.dumps(out, ensure_ascii=False)) ' "$@" } # --------------------------------------------------------------------------- # HTTP # # Setzt API_STATUS und API_BODY. Bricht NICHT von selbst ab - der Aufrufer # entscheidet, ob ein 404 ein Fehler ist. Bei 429 wird gewartet und wiederholt, # mit verdoppeltem Abstand; das ist die im Handbuch verlangte Reaktion und # gehoert an genau eine Stelle statt in jeden Aufruf. # --------------------------------------------------------------------------- API_STATUS=0 API_BODY='' api() { local method="$1" path="$2" payload="${3:-}" local url="$DC_URL$path" local attempt=0 delay=2 raw while :; do local -a args=(--silent --show-error --location --max-time 60 -w '\n%{http_code}' -X "$method" -H 'Accept: application/json' -H "User-Agent: dc-testkit/$DC_TESTKIT_VERSION") [ -n "$DC_TOKEN" ] && args+=(-H "Authorization: Bearer $DC_TOKEN") if [ -n "$payload" ]; then args+=(-H 'Content-Type: application/json' --data-binary "$payload") fi if ! raw=$(curl "${args[@]}" "$url" 2>&1); then API_STATUS=0 API_BODY="$raw" return 1 fi API_STATUS="${raw##*$'\n'}" API_BODY="${raw%$'\n'*}" # 429 und 5xx sind keine Aussage ueber die Sache, sondern ueber den # Zeitpunkt. Dreimal wiederholen, dann aufgeben. if { [ "$API_STATUS" = "429" ] || [ "$API_STATUS" -ge 500 ] 2>/dev/null; } && [ "$attempt" -lt 3 ]; then attempt=$((attempt + 1)) warn "HTTP $API_STATUS von $path - erneut in ${delay}s (Versuch $attempt/3)" sleep "$delay" delay=$((delay * 2)) continue fi break done [ "$API_STATUS" -ge 200 ] 2>/dev/null && [ "$API_STATUS" -lt 300 ] } # Fehlermeldung aus der Antwort ziehen. Der code ist stabil und fuer Programme # gedacht, die message fuer Menschen - beide ausgeben, damit ein Agent das eine # auswerten und das andere zitieren kann. api_error() { local code msg code=$(printf '%s' "$API_BODY" | json_get '.error.code' 2>/dev/null || true) msg=$(printf '%s' "$API_BODY" | json_get '.error.message' 2>/dev/null || true) if [ -n "$code" ]; then printf 'HTTP %s [%s] %s' "$API_STATUS" "$code" "$msg" else printf 'HTTP %s %s' "$API_STATUS" "$(printf '%s' "$API_BODY" | head -c 400)" fi } # --------------------------------------------------------------------------- # Zustand je Projekt # --------------------------------------------------------------------------- require_project() { [ -n "$DC_PROJECT" ] || die "Kein Projekt gesetzt. DC_PROJECT= oder --project ." } # Liest env.DC_TOKEN aus der .mcp.json eines Projektverzeichnisses. # # Gesucht wird an den ueblichen Stellen: im Verzeichnis selbst und eine Ebene # tiefer - Projekte legen die Datei mal neben die .sln, mal in den Unterordner # mit dem gleichnamigen Projekt. Ein Platzhalter wie ${DC_TOKEN} zaehlt nicht # als Token; sonst laeuft der Aufruf mit einer Zeichenkette los, die der Server # nur mit 401 beantworten kann. token_aus_projekt() { local wurzel="$1" datei for datei in "$wurzel/.mcp.json" "$wurzel"/*/.mcp.json; do [ -f "$datei" ] || continue python3 -c ' import json, re, sys try: with open(sys.argv[1], encoding="utf-8-sig") as fh: daten = json.load(fh) except Exception: sys.exit(1) for server in (daten.get("mcpServers") or {}).values(): tok = (server.get("env") or {}).get("DC_TOKEN") if isinstance(tok, str) and tok and not re.search(r"[$%<]", tok): print(tok) sys.exit(0) sys.exit(1) ' "$datei" && return 0 done return 1 } # Wird einmal in main() aufgerufen, bevor irgendein Befehl laeuft - damit auch # doctor dasselbe Token sieht wie jeder andere Aufruf. Setzt DC_TOKEN_QUELLE # fuer die Auskunft: Wo etwas herkommt, gehoert zu einem Bericht dazu. DC_TOKEN_QUELLE='' token_aufloesen() { if [ -n "$DC_TOKEN" ]; then DC_TOKEN_QUELLE="Konfiguration/Umgebung" return 0 fi [ -n "$DC_PROJECT_DIR" ] || return 0 DC_TOKEN="$(token_aus_projekt "$DC_PROJECT_DIR" || true)" [ -n "$DC_TOKEN" ] && DC_TOKEN_QUELLE="$DC_PROJECT_DIR/.mcp.json" return 0 } require_token() { [ -n "$DC_TOKEN" ] && return 0 # Die Meldung nennt beide Wege und sagt ausdruecklich, warum die Datei # fehlen KANN, ohne dass etwas kaputt ist. Ohne diesen Satz sucht der # Naechste den Fehler im Repo, im Netz oder in seinen Rechten - und findet # keinen, weil es keinen gibt. printf '%sFehler%s Kein Werkbank-Token.\n\n' "$C_RED" "$C_RESET" >&2 if [ -n "$DC_PROJECT_DIR" ]; then printf 'In %s liegt keine .mcp.json mit einem DC_TOKEN.\n\n' "$DC_PROJECT_DIR" >&2 printf 'Das ist kein Fehler im Repo: .mcp.json ist per .gitignore ausgeschlossen\n' >&2 printf 'und wird ueber Git NICHT mitgeliefert. Auf einer frisch geklonten\n' >&2 printf 'Maschine fehlt sie deshalb immer.\n\n' >&2 printf 'Das Dev-Agent-Token dieses Projekts muss von Hand hierher gebracht\n' >&2 printf 'werden - es steht in der .mcp.json des Projekts auf dem Arbeitsplatz.\n' >&2 else printf 'Zwei Wege, einer genuegt:\n\n' >&2 printf ' DC_PROJECT_DIR= - Token aus dessen .mcp.json\n' >&2 printf ' DC_TOKEN= in %s\n' "$CONFIG_FILE" >&2 fi exit 1 } state_dir() { require_project printf '%s/%s' "$STATE_ROOT" "$DC_PROJECT" } sandbox_dir() { printf '%s/sandbox' "$(state_dir)" } agent_bin() { printf '%s/update-agent' "$(state_dir)" } key_file() { printf '%s/license.key' "$(state_dir)" } ensure_state() { local dir dir="$(state_dir)" mkdir -p "$dir" chmod 700 "$dir" 2>/dev/null || true } # --------------------------------------------------------------------------- # Fassungen und Rechte # --------------------------------------------------------------------------- # Ist $1 aelter als $2? 0 = ja, 1 = nein, 2 = nicht vergleichbar. # # Nicht "sort -V": das fehlt auf BusyBox-Systemen, und die Antwort waere dort # still falsch statt laut fehlend. Ein Vorabkennzeichen (1.2.3-rc1) wird # abgeschnitten - fuer die Frage "reicht der Server aus" genuegt das. version_lt() { local a="${1%%-*}" b="${2%%-*}" i av bv for i in 1 2 3; do av=$(printf '%s' "$a" | cut -d. -f$i) bv=$(printf '%s' "$b" | cut -d. -f$i) av="${av:-0}"; bv="${bv:-0}" case "$av$bv" in ''|*[!0-9]*) return 2 ;; esac [ "$av" -lt "$bv" ] && return 0 [ "$av" -gt "$bv" ] && return 1 done return 1 } # Deckt die Rechteliste $1 (JSON-Array aus whoami) das Recht $2 ab? # # Die Wildcards muessen mitgedacht werden: "*" deckt alles ab, "bugtracker:*" # alles darunter. Sonst meldete das Werkzeug ein Recht als fehlend, das der # Server anerkennt - und schickte jemanden ein Token neu bauen, das passt. has_scope() { local list="$1" want="$2" prefix prefix="${2%%:*}" case "$list" in *'"*"'*) return 0 ;; *"\"$want\""*) return 0 ;; *"\"$prefix:*\""*) return 0 ;; esac return 1 } # --------------------------------------------------------------------------- # Der eigene Stand # # Das Skript liegt auf jeder Testmaschine als Kopie und altert dort still vor # sich hin. Genau daran ist die Rechtepruefung in doctor einmal gescheitert: # Sie war laengst geschrieben, aber auf der Maschine lag der Stand von davor - # und weil beide dieselbe Fassung meldeten, sah es niemand. # --------------------------------------------------------------------------- # Wo liegt dieses Skript wirklich? readlink loest einen Verweis in ~/bin auf - # sonst ersetzte selfupdate den Verweis und nicht die Datei dahinter. own_path() { local p="$0" if command -v readlink >/dev/null 2>&1 && readlink -f "$p" >/dev/null 2>&1; then readlink -f "$p" return 0 fi case "$p" in /*) printf '%s\n' "$p" ;; *) printf '%s/%s\n' "$(pwd)" "$p" ;; esac } # Fassung aus einer Skriptdatei lesen - der eigenen oder einer bezogenen. testkit_version_of() { sed -n 's/^DC_TESTKIT_VERSION="\([^"]*\)".*/\1/p' "$1" | head -n 1 } # Holt das ausgelieferte Skript nach $1. # 0 = da und plausibel, 1 = Bezug fehlgeschlagen, 2 = das ist kein Testkit # # Der Plausibilitaetstest ist nicht ueberflaessig: Faellt die Adresse aus oder # steht dort eine Anmeldeseite, schriebe der Bezug eine HTML-Seite in die # Datei. Danach laesst sich nichts mehr aufrufen - auch nicht das selfupdate, # das den Schaden beheben koennte. fetch_testkit() { local ziel="$1" curl -fsSL --max-time 60 "$DC_TESTKIT_URL" -o "$ziel" 2>/dev/null || return 1 head -n 1 "$ziel" | grep -q '^#!' || return 2 grep -q '^DC_TESTKIT_VERSION=' "$ziel" || return 2 return 0 } cmd_selfupdate() { local pruefen=0 erzwingen=0 while [ $# -gt 0 ]; do case "$1" in --pruefen|--check) pruefen=1 ;; --force) erzwingen=1 ;; *) die "Unbekannter Schalter: $1" ;; esac shift done local ziel tmp neu rc=0 ziel="$(own_path)" step "Bezug von $DC_TESTKIT_URL" # Die Zwischendatei muss neben dem Ziel liegen: nur im selben Dateisystem # ist das abschliessende mv ein Umbenennen und damit unteilbar. Ueber eine # Dateisystemgrenze hinweg kopiert mv Byte fuer Byte - und wer das Skript # in genau dem Moment aufruft, bekommt eine halbe Datei. tmp="$ziel.neu.$$" fetch_testkit "$tmp" || rc=$? case $rc in 1) rm -f "$tmp"; die "Bezug fehlgeschlagen - erreichbar? $DC_TESTKIT_URL" ;; 2) rm -f "$tmp"; die "Dort liegt kein Testkit. Nichts veraendert." ;; esac neu="$(testkit_version_of "$tmp")" if cmp -s "$tmp" "$ziel"; then rm -f "$tmp" ok "bereits aktuell (${neu:-?})" return 0 fi # In welche Richtung geht der Unterschied? Davon haengt alles Weitere ab - # der Rat genauso wie die Frage, ob ueberhaupt ersetzt werden darf. Eine # aeltere Fassung ueber eine neuere zu ziehen ist fast immer ein Versehen: # typisch der Fall, dass jemand im Repo am Skript arbeitet und dem Rat # "beheben mit selfupdate" folgt. Rueckgaengig machen laesst sich das # nicht, wenn die Aenderung noch nirgends sonst liegt. local abstieg=0 version_lt "${neu:-0}" "$DC_TESTKIT_VERSION" || abstieg=$? local gleiche_nummer=0 [ "$DC_TESTKIT_VERSION" = "${neu:-}" ] && gleiche_nummer=1 if [ "$pruefen" = '1' ]; then rm -f "$tmp" if [ "$gleiche_nummer" = '1' ]; then warn "gleiche Fassung ($DC_TESTKIT_VERSION), aber anderer Inhalt" note " Entweder ist diese Kopie von Hand geaendert worden, oder beim" note " Ausliefern wurde DC_TESTKIT_VERSION nicht hochgezaehlt." return 1 fi if [ "$abstieg" = '0' ]; then ok "neuer als ausgeliefert ($DC_TESTKIT_VERSION > ${neu:-?}) - Entwicklungsstand" note " Kein selfupdate noetig; es holte die aeltere Fassung zurueck." return 0 fi warn "veraltet: hier laeuft $DC_TESTKIT_VERSION, ausgeliefert wird ${neu:-?}" note " Beziehen mit: dc-testkit selfupdate" return 1 fi if [ "$erzwingen" != '1' ]; then if [ "$gleiche_nummer" = '1' ]; then rm -f "$tmp" warn "Gleiche Fassung ($DC_TESTKIT_VERSION), aber anderer Inhalt." die "Welche der beiden neuer ist, sagt hier nichts. Wenn du es weisst: --force" fi if [ "$abstieg" = '0' ]; then rm -f "$tmp" warn "Ausgeliefert wird ${neu:-?} - hier laeuft bereits $DC_TESTKIT_VERSION." die "Das waere ein Rueckschritt. Wenn das gewollt ist: --force" fi fi if [ ! -w "$ziel" ]; then rm -f "$tmp" die "Nicht beschreibbar: $ziel" fi chmod +x "$tmp" 2>/dev/null || true # Eine Sicherung, bevor etwas ersetzt wird. Der Rueckweg muss ohne Netz # gehen: Wer sich gerade eine kaputte Fassung geholt hat, kann sie nicht # noch einmal herunterladen, um das rueckgaengig zu machen. cp -p "$ziel" "$ziel.vorher" 2>/dev/null || true mv "$tmp" "$ziel" ok "$DC_TESTKIT_VERSION -> ${neu:-?}" dim " Vorherige Fassung: $ziel.vorher" } # --------------------------------------------------------------------------- # Oberflaechenpruefung # # Der generische Teil des Verfahrens aus TESTUMGEBUNG.md 5.8: ein eigenes # Display starten, aufnehmen, wieder abraeumen. Was dazwischen geklickt wird, # bleibt projektspezifisch und gehoert nicht hierher. # # Warum ueberhaupt gekapselt: Drei Fehler wiederholen sich sonst in jedem # Eigenbau-Skript - DISPLAY nicht gesetzt (dann landet alles auf :0, dem # echten Desktop), "pkill Xvfb" ohne Nummer (das erschlaegt die Sitzungen der # anderen Projekte), und Dateinamen ohne Projektpraefix (zwei Laeufe # ueberschreiben sich gegenseitig die Aufnahmen). # --------------------------------------------------------------------------- require_display() { [ -n "$DC_DISPLAY" ] \ || die "Kein Display gesetzt. DC_DISPLAY=: in $CONFIG_FILE - die Nummer steht in TESTUMGEBUNG.md 7." case "$DC_DISPLAY" in :0|:1) die "$DC_DISPLAY ist der echte Desktop bzw. der Anmeldebildschirm. Nimm die Nummer deines Projekts." ;; :[0-9]*) ;; *) die "DC_DISPLAY muss die Form : haben, nicht \"$DC_DISPLAY\"." ;; esac } display_nr() { printf '%s' "${DC_DISPLAY#:}" } # Laeuft auf diesem Display ein Xvfb? 0 = ja. display_laeuft() { pgrep -f "Xvfb $DC_DISPLAY" >/dev/null 2>&1 } # Gibt es den Socket, aber keinen Prozess? Dann ist die Nummer scheinbar # belegt und in Wahrheit frei - das Ueberbleibsel eines hart beendeten Xvfb. display_verwaist() { [ -e "/tmp/.X11-unix/X$(display_nr)" ] && ! display_laeuft } cmd_display() { local aktion="${1:-status}" require_display case "$aktion" in status) step "Display $DC_DISPLAY" if display_laeuft; then ok "belegt - $(pgrep -af "Xvfb $DC_DISPLAY" | head -1)" elif display_verwaist; then befund "verwaister Socket /tmp/.X11-unix/X$(display_nr) - kein Prozess dazu" note " Aufraeumen: sudo rm -f /tmp/.X11-unix/X$(display_nr)" return 1 else ok "frei" fi step "Belegt insgesamt" ls /tmp/.X11-unix/ 2>/dev/null | tr '\n' ' ' | sed 's/^/ Sockets: /' printf '\n' pgrep -af Xvfb 2>/dev/null | sed 's/^/ /' || note " kein Xvfb" ;; start) require_project local geometrie="${2:-1600x1000x24}" if display_laeuft; then ok "Display $DC_DISPLAY laeuft bereits" return 0 fi if display_verwaist; then die "Socket /tmp/.X11-unix/X$(display_nr) ohne Prozess. Erst aufraeumen (sudo rm -f), sonst startet Xvfb nicht." fi command -v Xvfb >/dev/null 2>&1 || die "Xvfb ist nicht installiert." step "Display $DC_DISPLAY starten ($geometrie)" Xvfb "$DC_DISPLAY" -screen 0 "$geometrie" >/dev/null 2>&1 & sleep 2 display_laeuft || die "Xvfb ist nicht hochgekommen. Ist $DC_DISPLAY anderweitig belegt?" ok "laeuft - in eigenen Aufrufen: export DISPLAY=$DC_DISPLAY" ;; stop) if ! display_laeuft; then ok "Display $DC_DISPLAY laeuft nicht" return 0 fi # Mit Nummer, nie ohne: "pkill Xvfb" beendet die Sitzungen aller # anderen Projekte auf dieser Maschine gleich mit. step "Display $DC_DISPLAY beenden" pkill -f "Xvfb $DC_DISPLAY" 2>/dev/null || true sleep 1 display_laeuft && die "Xvfb auf $DC_DISPLAY laeuft weiter." ok "beendet" ;; *) die "Aufruf: dc-testkit display [status|start [BxHxT]|stop]" ;; esac } cmd_shot() { require_project require_display local name="${1:-}" [ -n "$name" ] || die "Aufruf: dc-testkit shot (z.B. dc-testkit shot 1_start)" display_laeuft || die "Auf $DC_DISPLAY laeuft kein Xvfb. Erst: dc-testkit display start" command -v import >/dev/null 2>&1 || die "ImageMagick fehlt (import)." # Projektpraefix ist Pflicht, nicht Geschmack: /tmp gehoert allen, und # /tmp/1_start.png ueberschreibt die Aufnahme des Nachbarprojekts. local ziel="/tmp/${DC_PROJECT}_${name}.png" DISPLAY="$DC_DISPLAY" import -window root "$ziel" \ || die "Aufnahme fehlgeschlagen. Zeichnet auf $DC_DISPLAY ueberhaupt etwas?" ok "$ziel" } # --------------------------------------------------------------------------- # doctor - laeuft hier ueberhaupt etwas? # # Vor dem ersten Testlauf, und als Erstes, wenn etwas unerklaerlich scheitert. # Prueft von unten nach oben: Werkzeuge, Netz, Token, Projekt. # --------------------------------------------------------------------------- cmd_doctor() { local failed=0 step "Werkzeuge" for tool in curl python3; do if command -v "$tool" >/dev/null 2>&1; then ok "$tool $(command -v "$tool")" else befund "$tool fehlt - ohne das geht nichts" failed=1 fi done step "Deploymentcenter unter $DC_URL" if api GET /api/health; then local healthy server_version healthy=$(printf '%s' "$API_BODY" | json_get '.healthy' || echo '?') server_version=$(printf '%s' "$API_BODY" | json_get '.version' || echo '') ok "erreichbar, healthy=$healthy, Fassung ${server_version:-?}" # Die Fassung stand doctor immer schon vor Augen - verglichen wurde sie # nur nie. Deshalb meldete er "Bereit" und der erste Schritt, der den # Server wirklich braucht, scheiterte danach mit "kennt # /api/license/v1/dev-key noch nicht". if [ -z "$server_version" ]; then befund "der Server nennt keine Fassung - $DC_MIN_SERVER nicht pruefbar" else # Der Rueckgabewert MUSS mit || aufgefangen werden. Ohne das # beendet "set -e" das Skript, sobald version_lt etwas anderes als # 0 liefert - also ausgerechnet im Normalfall "Server ist neu # genug" (Rueckgabewert 1). doctor stieg dann wortlos nach der # Health-Pruefung aus, ohne Token und Projekt je anzusehen. local vergleich=0 version_lt "$server_version" "$DC_MIN_SERVER" || vergleich=$? case $vergleich in 0) befund "zu alt: $server_version - gebraucht wird mindestens $DC_MIN_SERVER" befund " Es fehlen: dc-testkit license (dev-key), der Bezug ueber /testkit/" befund " und die Rechtepruefung. Der Server muss deployt werden." failed=1 ;; 2) befund "Fassung \"$server_version\" ist nicht auswertbar - $DC_MIN_SERVER ungeprueft" ;; *) ok "erfuellt die Mindestfassung $DC_MIN_SERVER" ;; esac fi else befund "nicht erreichbar: $(api_error)" failed=1 fi # Der eigene Stand. Diese Pruefung gehoert VOR alles Weitere: Meldet ein # veraltetes Testkit "Bereit", ist jede folgende Zeile dieses Berichts # unter Vorbehalt - sie stammt aus einer Fassung, die niemand mehr liest. step "Dieses Testkit" dim " Fassung $DC_TESTKIT_VERSION - $(own_path)" local tk_tmp tk_rc=0 tk_neu tk_tmp="${TMPDIR:-/tmp}/dc-testkit.pruefung.$$" fetch_testkit "$tk_tmp" || tk_rc=$? if [ "$tk_rc" = '0' ]; then tk_neu="$(testkit_version_of "$tk_tmp")" if cmp -s "$tk_tmp" "$(own_path)"; then ok "aktuell - entspricht der ausgelieferten Fassung ${tk_neu:-?}" else # Drei verschiedene Lagen, und nur eine davon ruft nach selfupdate. # Sie zu verwechseln waere teuer: Wer im Repo am Skript arbeitet, # haette auf den Rat "beheben mit selfupdate" hin seine eigene, # noch nicht ausgelieferte Arbeit ueberschrieben. local tk_cmp=0 version_lt "$DC_TESTKIT_VERSION" "${tk_neu:-0}" || tk_cmp=$? case $tk_cmp in 0) befund "veraltet: hier laeuft $DC_TESTKIT_VERSION, ausgeliefert wird ${tk_neu:-?}" befund " Beheben mit: dc-testkit selfupdate" failed=1 ;; 1) if [ "$DC_TESTKIT_VERSION" = "${tk_neu:-}" ]; then # Der stillste Fall und deshalb der gefaehrlichste: # gleiche Nummer, anderer Inhalt. Genau daran ist die # Rechtepruefung einmal unbemerkt vorbeigelaufen. befund "gleiche Fassung ($DC_TESTKIT_VERSION), aber anderer Inhalt" befund " Entweder ist diese Kopie von Hand geaendert worden, oder beim" befund " Ausliefern wurde DC_TESTKIT_VERSION nicht hochgezaehlt." failed=1 else ok "neuer als ausgeliefert ($DC_TESTKIT_VERSION > ${tk_neu:-?}) - Entwicklungsstand" dim " Kein selfupdate: das holte die aeltere Fassung zurueck." fi ;; *) befund "Fassungen nicht vergleichbar: $DC_TESTKIT_VERSION / ${tk_neu:-?}" ;; esac fi else befund "ausgelieferte Fassung nicht abrufbar - der eigene Stand bleibt ungeprueft" dim " $DC_TESTKIT_URL" fi rm -f "$tk_tmp" step "Token" if [ -z "$DC_TOKEN" ]; then befund "kein Werkbank-Token" if [ -n "$DC_PROJECT_DIR" ]; then befund " In $DC_PROJECT_DIR liegt keine .mcp.json mit DC_TOKEN." befund " Das ist kein Fehler im Repo: .mcp.json ist per .gitignore" befund " ausgeschlossen und kommt ueber Git nicht mit. Auf einer frisch" befund " geklonten Maschine fehlt sie deshalb immer - das Token muss von" befund " Hand hierher, aus der .mcp.json des Projekts auf dem Arbeitsplatz." else befund " Entweder DC_PROJECT_DIR auf das Projekt-Repo zeigen lassen" befund " (Token aus dessen .mcp.json) oder DC_TOKEN in $CONFIG_FILE." fi failed=1 elif api GET /api/tokens/v1/whoami; then # Fruehere Fassungen meldeten hier nur "gueltig - der Bugtracker # antwortet". Das lief auch mit einem Laufzeit-Token durch, dem # bugtracker:manage und license:dev fehlten - der Lauf scheiterte dann # erst dort, wo die Rechte wirklich gebraucht wurden. local tok_name tok_owner tok_project tok_scopes tok_workbench want tok_name=$(printf '%s' "$API_BODY" | json_get '.name' || echo '?') tok_owner=$(printf '%s' "$API_BODY" | json_get '.owner_type' || echo '?') tok_project=$(printf '%s' "$API_BODY" | json_get '.project_slug' || echo '') tok_scopes=$(printf '%s' "$API_BODY" | json_get '.scopes' || echo '[]') tok_workbench=$(printf '%s' "$API_BODY" | json_get '.is_workbench' || echo 'false') ok "gueltig - \"$tok_name\", Besitzerart $tok_owner, Projekt ${tok_project:-keins}" dim " Rechte: $tok_scopes" dim " Herkunft: ${DC_TOKEN_QUELLE:-unbekannt}" if [ "$tok_workbench" != 'true' ]; then befund "kein Werkbank-Token: Besitzerart ist $tok_owner, gebraucht wird dev_agent" befund " Ein Laufzeit-Token darf nicht in fremde Projekte melden (TESTUMGEBUNG.md 4.2)." failed=1 fi for want in bugtracker:manage license:dev updateservice:read; do if ! has_scope "$tok_scopes" "$want"; then befund "Recht fehlt: $want" failed=1 fi done # Ein Token ohne Projektbindung rutschte hier bisher durch: geprueft # wurde nur, ob eine vorhandene Bindung zum Projekt passt. Genau so ein # Token lag auf der Test-VM - dev_agent, aber project_slug leer. # # Ohne Bindung ist das Token nicht eingeschraenkt, sondern # uneingeschraenkt: Der Server leitet kein Heimatprojekt ab, also ist # fuer ihn jede Meldung eine Heimmeldung. Damit faellt der ganze Schutz # fuer Fremdmeldungen weg - keine Begruendungspflicht, keine Deckelung # auf medium, kein Eingang in triage_external, kein reported_from. if [ -z "$tok_project" ]; then befund "Token ohne Projektbindung (project_slug ist leer)" befund " Damit zaehlt JEDE Meldung als Heimmeldung: ohne Begruendungspflicht," befund " ohne Deckelung auf medium, direkt in die offene Liste des fremden" befund " Projekts - und ohne nachweisbare Herkunft. Im WebUI nachtragen." failed=1 elif [ -n "$DC_PROJECT" ] && [ "$tok_project" != "$DC_PROJECT" ]; then befund "Token gehoert zu $tok_project, gearbeitet wird an $DC_PROJECT" befund " Je Projekt ein eigenes Token - sonst ist die Herkunft einer Meldung nicht mehr ablesbar." failed=1 fi elif [ "$API_STATUS" = '404' ]; then # Server ohne whoami. Dann bleibt nur die alte, grobe Pruefung - und # der ausdrueckliche Hinweis, dass die Rechte ungeprueft sind. Still # "gueltig" zu melden waere genau der Fehler, der hier behoben wird. if api GET /api/bugtracker/v1/projects; then befund "gueltig, aber die Rechte sind ungeprueft" befund " /api/tokens/v1/whoami gibt es erst ab $DC_MIN_SERVER." else befund "Token wird nicht angenommen: $(api_error)" failed=1 fi else befund "Token wird nicht angenommen: $(api_error)" failed=1 fi step "Projekt" if [ -z "$DC_PROJECT" ]; then befund "kein Projekt gesetzt (DC_PROJECT)" failed=1 elif [ -z "$DC_TOKEN" ]; then # Ohne Token ist die Projektliste nicht abrufbar. Das stillschweigend # zu ueberspringen liesse den Eindruck entstehen, das Projekt sei # geprueft worden. befund "$DC_PROJECT nicht pruefbar - dafuer wird ein Token gebraucht" elif api GET /api/bugtracker/v1/projects; then if printf '%s' "$API_BODY" | grep -q "\"$DC_PROJECT\""; then ok "$DC_PROJECT ist im Deploymentcenter bekannt" else befund "$DC_PROJECT kommt in der Projektliste nicht vor" failed=1 fi else befund "Projektliste nicht abrufbar: $(api_error)" failed=1 fi # Die Oberflaechenpruefung ist der zweite Grund, warum es diese Maschine # gibt (5.8) - doctor sah sie bisher gar nicht an. Wer sie braucht, merkte # das Fehlen erst mitten im Lauf, mit einem "XOpenDisplay failed" statt # einer Auskunft. step "Oberflaechenpruefung" local gui_fehlt='' for tool in Xvfb xdotool import convert; do command -v "$tool" >/dev/null 2>&1 || gui_fehlt="$gui_fehlt $tool" done if [ -n "$gui_fehlt" ]; then # Kein failed=1: Ein Projekt ohne Oberflaeche braucht davon nichts. # Ein Befund waere hier eine Falschmeldung - aber schweigen darf das # Werkzeug auch nicht. note " nicht moeglich, es fehlt:$gui_fehlt" note " Nur noetig, wenn dieses Projekt eine Oberflaeche hat." else ok "Xvfb, xdotool, ImageMagick vorhanden" fi if [ -z "$DC_DISPLAY" ]; then dim " Kein DC_DISPLAY gesetzt - display/shot bleiben aus." dim " Die Nummer dieses Projekts steht in TESTUMGEBUNG.md 7." else case "$DC_DISPLAY" in :0|:1) befund "DC_DISPLAY=$DC_DISPLAY ist der echte Desktop bzw. der Anmeldebildschirm" befund " Darauf zu zeichnen stoert den Menschen, der dort arbeitet." failed=1 ;; *) if display_laeuft; then ok "Display $DC_DISPLAY - belegt (vermutlich von einem eigenen Lauf)" elif display_verwaist; then befund "Display $DC_DISPLAY - verwaister Socket, kein Prozess dazu" befund " Die Nummer wirkt belegt und ist frei. Aufraeumen:" befund " sudo rm -f /tmp/.X11-unix/X${DC_DISPLAY#:}" failed=1 else ok "Display $DC_DISPLAY - frei" fi ;; esac fi step "Arbeitsplatz" if [ -n "$DC_PROJECT" ]; then dim " Zustand: $(state_dir)" dim " Sandkasten: $(sandbox_dir)" dim " Kennung: $DC_INSTANCE" fi dim " Platz: $(df -Ph "$HOME" | awk 'NR==2 {print $4 " frei"}')" if [ "$failed" -eq 0 ]; then printf '\n%sBereit.%s\n' "$C_GREEN" "$C_RESET" else printf '\n%sNicht bereit - siehe oben.%s\n' "$C_YELLOW" "$C_RESET" return 1 fi } # --------------------------------------------------------------------------- # license - DEV-Schluessel holen, verlaengern, zurueckgeben # # Der Aufruf ist absichtlich wiederholbar. Er darf am Anfang jedes Laufs # stehen: Gibt es fuer diese Maschine schon einen Schluessel, kommt derselbe # zurueck und seine Frist wird verlaengert. # # Der Schluessel wird in eine Datei mit Rechten 600 geschrieben und NICHT # ausgegeben. Ausgabe nur auf ausdrueckliches --print - sonst steht er im # Terminalprotokoll, im Sitzungsverlauf des Agenten und am Ende in einem # Bugreport. # --------------------------------------------------------------------------- cmd_license() { require_project; require_token; ensure_state local ttl='' mode='issue' print=0 while [ $# -gt 0 ]; do case "$1" in --ttl) ttl="$2"; shift 2 ;; --revoke) mode='revoke'; shift ;; --print) print=1; shift ;; *) die "Unbekannter Schalter fuer license: $1" ;; esac done local payload payload=$(json_object \ project_slug "$DC_PROJECT" \ instance_id "$DC_INSTANCE" \ ttl_days "$ttl" \ action_mode "$mode") if ! api POST /api/license/v1/dev-key "$payload"; then die "DEV-Schluessel nicht erhalten: $(api_error)" fi # Ein Deploymentcenter vor 2.6.1 kennt die Aktion nicht. Es antwortet dann # aber NICHT mit 404: Die Lizenz-API faellt bei unbekannten Pfadsegmenten # auf 'status' zurueck und liefert HTTP 200 mit {"status":"ok","module": # "license"}. Ohne diese Pruefung sucht der erste Agent den Fehler bei sich. if [ "$(printf '%s' "$API_BODY" | json_get '.module' 2>/dev/null || true)" = 'license' ]; then die "Dieses Deploymentcenter kennt /api/license/v1/dev-key noch nicht. Der Server antwortet mit der Verfuegbarkeitspruefung des Lizenzmoduls statt mit einem Schluessel - die Fassung ist aelter als 2.6.1. Es muss deployt werden." fi if [ "$mode" = 'revoke' ]; then rm -f "$(key_file)" ok "$(printf '%s' "$API_BODY" | json_get '.message')" return 0 fi local key expires key=$(printf '%s' "$API_BODY" | json_get '.license_key') \ || die "Antwort enthaelt keinen Schluessel: $API_BODY" expires=$(printf '%s' "$API_BODY" | json_get '.expires_at' || echo '?') local file file="$(key_file)" ( umask 077; printf '%s' "$key" > "$file" ) ok "$(printf '%s' "$API_BODY" | json_get '.message')" dim " gilt bis: $expires UTC" dim " Aktivierungen: $(printf '%s' "$API_BODY" | json_get '.activations_used')/$(printf '%s' "$API_BODY" | json_get '.max_activations')" dim " abgelegt in: $file" [ "$print" -eq 1 ] && printf '%s\n' "$key" return 0 } load_key() { local file file="$(key_file)" [ -f "$file" ] || die "Kein DEV-Schluessel vorhanden. Zuerst: dc-testkit license" cat "$file" } # --------------------------------------------------------------------------- # agent - den update-agent besorgen # # Mit Pruefsummenabgleich gegen installer.json. Das ist nicht Zierde: Ein # Testlauf, der ein halb heruntergeladenes Binary startet, meldet Fehler, die # es in der Anwendung nicht gibt. # --------------------------------------------------------------------------- cmd_agent() { require_project; ensure_state local force=0 [ "${1:-}" = '--force' ] && force=1 local bin bin="$(agent_bin)" if [ -x "$bin" ] && [ "$force" -eq 0 ]; then ok "update-agent liegt bereits vor ($bin)" return 0 fi local arch platform arch="$(uname -m)" case "$arch" in x86_64|amd64) platform='linux-x64' ;; aarch64|arm64) platform='linux-arm64' ;; *) die "Fuer die Architektur $arch gibt es kein update-agent-Binary." ;; esac step "update-agent fuer $platform holen" local manifest manifest=$(curl -fsSL --max-time 60 "$DC_URL/installer/installer.json") \ || die "installer.json nicht abrufbar." local expected file idx=0 found='' while :; do local p p=$(printf '%s' "$manifest" | json_get ".binaries[$idx].platform") || break if [ "$p" = "$platform" ]; then found="$idx" break fi idx=$((idx + 1)) done [ -n "$found" ] || die "installer.json kennt kein Binary fuer $platform." file=$(printf '%s' "$manifest" | json_get ".binaries[$found].file") expected=$(printf '%s' "$manifest" | json_get ".binaries[$found].sha256") curl -fSL --max-time 600 -o "$bin.part" "$DC_URL/installer/$file" \ || die "Download von $file fehlgeschlagen." local actual actual=$(sha256sum "$bin.part" | cut -d' ' -f1) if [ "$actual" != "$expected" ]; then rm -f "$bin.part" die "Pruefsumme weicht ab. Erwartet $expected, bekommen $actual." fi mv "$bin.part" "$bin" chmod +x "$bin" ok "update-agent $(printf '%s' "$manifest" | json_get '.version') abgelegt, Pruefsumme stimmt" } # Der Schluessel geht ueber die Umgebung, nicht ueber --license-key. # # Die Maschine ist geteilt. Was in der Befehlszeile steht, sieht jeder andere # Benutzer in "ps aux" - ein Lizenzschluessel gehoert da nicht hin, auch kein # befristeter. Die Umgebung eines Prozesses liest dagegen nur, wer ihm gehoert # (oder root). Der update-agent nimmt DC_LICENSE_KEY seit jeher entgegen und # bevorzugt es sogar gegenueber dem Schalter. run_agent() { local bin bin="$(agent_bin)" [ -x "$bin" ] || die "update-agent fehlt. Zuerst: dc-testkit agent" DC_LICENSE_KEY="$(load_key)" "$bin" \ --base-url "$DC_URL" \ --project "$DC_PROJECT" \ --channel "$DC_CHANNEL" \ "$@" } # --------------------------------------------------------------------------- # check / install / update / repair - die Auslieferungswege # # ZUM VERSTAENDNIS DES INSTALL-BEFEHLS: "--action install" des update-agent ist # ein Dialog mit Auswahllisten und Passwortabfrage. Er laesst sich nicht # kopflos treiben, und ein Agent darf ohnehin kein Passwort eingeben. Was hier # als "install" laeuft, ist deshalb die Erstauslieferung in ein LEERES # Verzeichnis ueber den Update-Weg. # # Das ist keine Notloesung: Der Installer holt die Dateien laut # SETUP_INTEGRATION_GUIDE ueber genau denselben Pfad - Download, SHA256, # Signaturpruefung, Staging, Rollback. Was der Dialog zusaetzlich tut, ist # fragen und eine Konfiguration schreiben. Geprueft wird hier also die # Auslieferung, nicht die Gespraechsfuehrung. # --------------------------------------------------------------------------- cmd_check() { require_project step "Auf Updates pruefen ($DC_PROJECT, Kanal $DC_CHANNEL)" run_agent --action check --target-dir "$(sandbox_dir)" "$@" } cmd_install() { require_project; ensure_state local dir dir="$(sandbox_dir)" if [ -d "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then die "Der Sandkasten $dir ist nicht leer. Erst 'dc-testkit cleanup --sandbox', sonst pruefst du ein Update statt einer Erstinstallation." fi mkdir -p "$dir" step "Erstauslieferung nach $dir" run_agent --action update --version latest --target-dir "$dir" "$@" } cmd_update() { require_project local version='latest' if [ "${1:-}" = '--version' ]; then version="$2"; shift 2 fi step "Update auf $version" run_agent --action update --version "$version" --target-dir "$(sandbox_dir)" "$@" } cmd_repair() { require_project step "Integritaetsreparatur" run_agent --action repair --target-dir "$(sandbox_dir)" "$@" } cmd_list() { require_project run_agent --action list "$@" } # --------------------------------------------------------------------------- # verify - was liegt jetzt eigentlich da? # # Ein "Update erfolgreich" auf der Konsole ist keine Aussage darueber, was im # Zielverzeichnis steht. Diese Pruefung liest das Manifest und vergleicht es # mit dem, was der Server als neueste Fassung fuehrt. # --------------------------------------------------------------------------- cmd_verify() { require_project local dir manifest dir="$(sandbox_dir)" manifest="$dir/manifest.json" step "Installation in $dir pruefen" [ -d "$dir" ] || die "$dir gibt es nicht - es wurde nichts installiert." local files files=$(find "$dir" -type f | wc -l) dim " Dateien: $files" [ "$files" -gt 0 ] || die "Das Zielverzeichnis ist leer." if [ ! -f "$manifest" ]; then warn "Keine manifest.json im Zielverzeichnis." warn "Ohne sie meldet der Agent beim naechsten Lauf 'Unbekannt' als installierte Fassung." return 1 fi local installed installed=$(json_get '.version' < "$manifest" || echo '') [ -n "$installed" ] || die "manifest.json enthaelt keine Version." ok "installiert: $installed" local platform case "$(uname -m)" in x86_64|amd64) platform='linux-x64' ;; aarch64|arm64) platform='linux-arm64' ;; *) platform='' ;; esac if api GET "/api/updateservice/v1/latest?product=$DC_PROJECT&channel=$DC_CHANNEL&platform=$platform"; then local latest latest=$(printf '%s' "$API_BODY" | json_get '.latest_release.version' || echo '') if [ -z "$latest" ]; then warn "Der Server nennt keine neueste Fassung fuer Kanal $DC_CHANNEL/$platform." elif [ "$latest" = "$installed" ]; then ok "entspricht der neuesten Fassung auf dem Server" else warn "Server fuehrt $latest, installiert ist $installed" return 1 fi else warn "Serverabgleich nicht moeglich: $(api_error)" fi return 0 } # --------------------------------------------------------------------------- # report - Fehler und Wuensche in den Bugtracker # # client_ref wird aus Projekt, Titel und Fundstelle abgeleitet, nicht # gewuerfelt. Damit legt derselbe Befund aus zehn Testlaeufen ein Item an und # nicht zehn - genau dafuer ist das Feld da. Wer bewusst ein zweites Item will, # gibt --ref selbst an. # --------------------------------------------------------------------------- # Welche Fassung wird hier gerade geprueft? # # Das ist die wichtigste Einzelangabe einer Meldung von der Testumgebung, und # sie von Hand mitzugeben vergisst jeder. Ohne sie kann der Server nicht # entscheiden, ob ein bereits behobener Fehler eine Regression ist oder bloss # ein veralteter Pruefstand - und legt im Zweifel ein Regressionsitem an. # # Quelle ist die manifest.json im Sandkasten, also genau die Fassung, gegen die # der Lauf tatsaechlich lief - nicht die, die jemand fuer aktuell haelt. detect_build() { local manifest manifest="$(sandbox_dir)/manifest.json" [ -f "$manifest" ] || return 1 json_get '.version' < "$manifest" 2>/dev/null || return 1 } cmd_report() { require_project; require_token local type='bug' title='' description='' severity='medium' file='' line='' local ref='' error_message='' stack='' build='' target_project='' evidence='' while [ $# -gt 0 ]; do case "$1" in --type) type="$2"; shift 2 ;; --title) title="$2"; shift 2 ;; --desc) description="$2"; shift 2 ;; --desc-file) description="$(cat "$2")"; shift 2 ;; --severity) severity="$2"; shift 2 ;; --file) file="$2"; shift 2 ;; --line) line="$2"; shift 2 ;; --error) error_message="$2"; shift 2 ;; --stack-file) stack="$(cat "$2")"; shift 2 ;; --build) build="$2"; shift 2 ;; --project) target_project="$2"; shift 2 ;; --evidence) evidence="$2"; shift 2 ;; --ref) ref="$2"; shift 2 ;; *) die "Unbekannter Schalter fuer report: $1" ;; esac done [ -n "$title" ] || die "--title fehlt." # Der Server kennt genau zwei Typen, und er ist dabei nicht waehlerisch, # sondern still: alles, was nicht woertlich "feature_request" heisst, wird # zu "bug". Ein "--type feature" landete damit als Fehler im Tracker, und # niemand haette es gemerkt. Deshalb hier uebersetzen statt durchreichen - # und alles Unbekannte ablehnen, statt es stillschweigend umzudeuten. case "$type" in bug) ;; feature|feature_request|fr|wunsch|verbesserung) type='feature_request' ;; idea|idee) die "\"idea\" ist kein Typ, sondern ein Schweregrad. Gemeint ist vermutlich: --type feature --severity idea" ;; *) die "Unbekannter Typ \"$type\". Erlaubt sind: bug, feature" ;; esac case "$severity" in idea|wishlist|low|medium|high|critical) ;; *) die "Unbekannter Schweregrad \"$severity\". Erlaubt sind: idea, wishlist, low, medium, high, critical" ;; esac # Beschreibung darf auch ueber die Standardeingabe kommen - Fehlertexte # entstehen meist als Ausgabe eines anderen Befehls. if [ -z "$description" ] && [ ! -t 0 ]; then description="$(cat)" fi [ -n "$description" ] || die "Keine Beschreibung. --desc, --desc-file oder ueber die Standardeingabe." target_project="${target_project:-$DC_PROJECT}" # Fassung nachtragen, wenn sie nicht ausdruecklich angegeben wurde. Nur fuer # das eigene Projekt: Bei einer Fremdmeldung ist die eigene Fassung nicht # die des gemeldeten Projekts, und eine falsche Angabe waere schlimmer als # keine - der Server verglicher sie sonst gegen dessen resolved_in_build. if [ -z "$build" ] && [ "$target_project" = "$DC_PROJECT" ]; then build="$(detect_build || true)" [ -n "$build" ] && dim " geprüfte Fassung: $build (aus manifest.json)" fi # Meldung gegen ein FREMDES Projekt verlangt eine Begruendung von # mindestens 40 Zeichen - der Server weist sie sonst mit 400 # missing_evidence ab. Der Grund steht in report.php: Ein Agent in Projekt X # kann kaum unterscheiden, ob das fremde Projekt kaputt ist oder ob X es # falsch benutzt, und die Voreinstellung eines hilfsbereiten Modells ist im # Zweifel "melde es". Hier abzufangen statt den Server ablehnen zu lassen # spart einen Fehlversuch und macht die Anforderung sichtbar. if [ "$target_project" != "$DC_PROJECT" ]; then if [ "${#evidence}" -lt 40 ]; then die "Meldung gegen das fremde Projekt \"$target_project\" verlangt --evidence mit mindestens 40 Zeichen: Was wurde beobachtet, und warum liegt die Ursache dort und nicht in \"$DC_PROJECT\"?" fi # Der Server deckelt den Schweregrad einer Fremdmeldung auf medium und # sagt es nicht. Wer "critical" schreibt und spaeter "medium" vorfindet, # haelt das fuer einen Fehler. Also vorher sagen, dass es so gemeint ist: # Die Dringlichkeit in einem fremden Projekt kann der Melder nicht # beurteilen - dazu muesste er wissen, wer sonst noch betroffen ist. case "$severity" in high|critical) warn "Schweregrad \"$severity\" wird bei einer Fremdmeldung auf \"medium\" gedeckelt." warn "Die Einstufung nimmt \"$target_project\" selbst vor - schreib das Dringliche in die Begruendung." ;; esac elif [ -n "$evidence" ]; then # Im eigenen Projekt verwirft der Server das Feld ohnehin. evidence='' fi if [ -z "$ref" ]; then ref="testkit-$(printf '%s|%s|%s|%s' "$target_project" "$title" "$file" "$line" \ | sha256sum | cut -c1-24)" fi # Der Testlauf gehoert in die Beschreibung: Ohne die Angabe, auf welcher # Maschine und gegen welche Fassung geprueft wurde, ist ein Befund von der # Testumgebung nicht von einem aus dem Feld zu unterscheiden. description="$description -- Gemeldet von dc-testkit $DC_TESTKIT_VERSION auf $DC_INSTANCE ($(uname -sr)), Kanal $DC_CHANNEL." local payload payload=$(json_object \ project_slug "$target_project" \ type "$type" \ title "$title" \ description "$description" \ error_message "$error_message" \ stack_trace "$stack" \ severity "$severity" \ environment 'development' \ build_version "$build" \ file_path "$file" \ line_no "$line" \ evidence "$evidence" \ client_ref "$ref") if ! api POST /api/bugtracker/v1/report "$payload"; then die "Meldung nicht angenommen: $(api_error)" fi # Feldnamen stammen aus public/api/bugtracker/v1/report.php - nicht raten, # der Endpunkt heisst sie item_id/is_new und nicht id/duplicate. local id is_new hits occurrences id=$(printf '%s' "$API_BODY" | json_get '.item_id' || echo '?') is_new=$(printf '%s' "$API_BODY" | json_get '.is_new' || echo 'false') hits=$(printf '%s' "$API_BODY" | json_get '.idempotent_hit' || echo 'false') occurrences=$(printf '%s' "$API_BODY" | json_get '.occurrence_count' || echo '') # Bekannt und in einer spaeteren Fassung behoben. Das ist der haeufigste # Fall auf einer Testumgebung, die nicht immer die neueste Fassung faehrt - # und die einzige Antwort, bei der NICHTS angelegt wurde. local fixed_in reported_build regression fixed_in=$(printf '%s' "$API_BODY" | json_get '.known_fixed_in' || echo '') reported_build=$(printf '%s' "$API_BODY" | json_get '.reported_build' || echo '') regression=$(printf '%s' "$API_BODY" | json_get '.regression_of' || echo '') if [ -n "$fixed_in" ]; then ok "Nicht gemeldet - schon behoben." dim " Item #$id, behoben in $fixed_in; geprüft wurde $reported_build." dim " Es wurde nichts angelegt und nichts hochgezählt." dim " Aktualisiere und prüfe erneut. Tritt es dann immer noch auf," dim " ist es eine Regression und gehört gemeldet." return 0 fi # Der Fix, der geprueft werden sollte, traegt nicht. Kein neues Item - # Versuch und Fehlschlag bleiben auf einem Strang. if [ "$(printf '%s' "$API_BODY" | json_get '.fix_insufficient' || echo false)" = 'true' ]; then warn "FIX TRAEGT NICHT: Item #$id steht wieder in der Arbeitsschlange." dim " Geprüft wurde $reported_build. Es wurde kein neues Item angelegt;" dim " der Beleg hängt am bestehenden." return 0 fi if [ -n "$regression" ]; then warn "REGRESSION: Item #$id ist ein Wiederauftreten von #$regression." warn "Der Fehler galt als behoben und ist in $reported_build wieder da." fi if [ "$is_new" = 'true' ]; then ok "Gemeldet als Item #$id in $target_project" elif [ "$hits" = 'true' ]; then ok "Schon gemeldet - Item #$id, unveraendert (client_ref $ref)" else ok "Wiederkehrendes Vorkommnis - Item #$id, nun ${occurrences:-?}x gezaehlt" fi # Eine Fremdmeldung landet NICHT in "open", sondern im Eingang # triage_external. Ohne diesen Hinweis sucht der Meldende sein Item in der # offenen Liste des fremden Projekts und findet es nicht - und meldet in # drei Wochen dieselbe Sache noch einmal. local status affected status=$(printf '%s' "$API_BODY" | json_get '.item_status' || echo '') affected=$(printf '%s' "$API_BODY" | json_get '.affects' || echo '') if [ "$status" = 'triage_external' ]; then dim " Status: triage_external - Eingang fuer Fremdmeldungen." dim " \"$target_project\" bestaetigt und stuft ein; vorher zaehlt es" dim " nicht in dessen Statistik. Herkunft ist als \"$DC_PROJECT\" vermerkt." [ -n "$affected" ] && [ "$affected" != '[]' ] && dim " Bisher betroffen gemeldet: $affected" elif [ -n "$status" ]; then dim " Status: $status" fi dim " $DC_URL/index.php#tab-bugtracker" } # --------------------------------------------------------------------------- # Arbeit uebernehmen und abschliessen # # Duenne Huellen um /manage. Sie sind hier, damit ein Agent den ganzen Kreis # ueber ein Werkzeug schliessen kann - uebernehmen, kommentieren, abschliessen - # statt fuer die Haelfte auf curl auszuweichen und dabei das Uebernehmen zu # vergessen. Wer nicht uebernimmt, arbeitet irgendwann parallel zu jemandem. # --------------------------------------------------------------------------- # Wohin darf ich ueberhaupt melden? # # Ein Agent, der in seinem Projekt einen Fehler des Deploymentcenters findet, # muss dessen Slug kennen. Ihn zu raten geht schief - eine Meldung an ein # unbekanntes Projekt wird abgewiesen, und der Agent legt sie dann ersatzweise # im eigenen ab, wo sie niemand findet, der sie beheben koennte. cmd_projects() { require_token if ! api GET /api/bugtracker/v1/projects; then die "Projektliste nicht abrufbar: $(api_error)" fi printf '%s' "$API_BODY" | python3 -c ' import json, sys data = json.load(sys.stdin) # Der Endpunkt hat die Liste ueber die Zeit unter verschiedenen Schluesseln # gefuehrt; hier wird genommen, was da ist, statt einen Namen zu erzwingen. items = None for key in ("projects", "items", "data"): if isinstance(data.get(key), list): items = data[key] break if items is None: items = [v for v in data.values() if isinstance(v, list)][:1] items = items[0] if items else [] if not items: print(" (keine Projekte gemeldet)") raise SystemExit(0) for entry in items: if isinstance(entry, str): print(" %s" % entry) continue slug = entry.get("slug") or entry.get("project_slug") or "?" name = entry.get("name") or "" print(" %-24s %s" % (slug, name)) ' dim "" dim "Eigenes Projekt: $DC_PROJECT" dim "In ein fremdes melden: --project --evidence \"...\" (mind. 40 Zeichen)" } cmd_next() { require_project; require_token local payload payload=$(json_object project_slug "$DC_PROJECT" limit "${1:-1}") if ! api POST '/api/bugtracker/v1/manage?action=next' "$payload"; then if printf '%s' "$API_BODY" | grep -q 'already_claimed'; then warn "Schon von jemand anderem uebernommen - naechstes Item nehmen." return 2 fi die "Kein Item erhalten: $(api_error)" fi # Eine Pruefaufgabe sieht im JSON aus wie jede andere Arbeit, ist aber eine # andere Frage: nicht "behebe das", sondern "traegt der Fix?". Ohne diesen # Hinweis faengt ein Agent an, einen bereits behobenen Fehler noch einmal zu # beheben. printf '%s' "$API_BODY" | python3 -c ' import json, sys try: data = json.load(sys.stdin) except Exception: sys.exit(0) for item in data.get("items") or []: if item.get("status") != "in_verification": continue print("") print(" NACHPRUEFUNG, keine neue Fehlersuche:") print(" Item #%s %s" % (item.get("id"), item.get("title"))) print(" Behoben laut Tracker in: %s" % (item.get("resolved_in_build") or "unbekannt")) if item.get("project_slug"): print(" Projekt: %s" % item["project_slug"]) print("") print(" Gegen diesen Build testen. Dann eines von beidem:") print(" traegt -> dc-testkit confirm %s \"kurz was geprueft wurde\"" % item.get("id")) print(" traegt nicht-> den Fehler erneut melden (dc-testkit report ...);") print(" das Item wechselt dann selbst auf fix_insufficient.") ' || true printf '%s\n' "$API_BODY" } # Nachpruefung bestaetigen. # # Bewusst getrennt von "resolve": resolve behauptet "ich habe es behoben, in # Build X" und setzt resolved_in_build. Hier wird nichts behauptet, sondern # etwas bestaetigt - der Fix eines anderen. Beides in einen Befehl zu legen # hiesse, den Unterschied zwischen Fixen und Pruefen einzuebnen. cmd_confirm() { require_token [ $# -ge 1 ] || die "Aufruf: dc-testkit confirm [was geprueft wurde]" local id="$1"; shift local note="${1:-}" local build build="$(detect_build || true)" local text="Nachpruefung bestanden${build:+ (geprueft gegen $build)}." [ -n "$note" ] && text="$text $note" local payload payload=$(json_object status closed notes "$text") api POST "/api/bugtracker/v1/manage?action=status&id=$id" "$payload" \ || die "Bestaetigung nicht angenommen: $(api_error)" ok "Item #$id bestaetigt und geschlossen${build:+ - geprüft gegen $build}" } cmd_comment() { require_token [ $# -ge 2 ] || die "Aufruf: dc-testkit comment [aktion]" local id="$1"; shift local comment="$1"; shift local action="${1:-investigated}" local payload payload=$(json_object comment "$comment" action_taken "$action") api POST "/api/bugtracker/v1/manage?action=comment&id=$id" "$payload" \ || die "Kommentar nicht angenommen: $(api_error)" ok "Kommentar an Item #$id" } cmd_resolve() { require_token [ $# -ge 2 ] || die "Aufruf: dc-testkit resolve [notizen]" local id="$1"; shift local build="$1"; shift local notes="${1:-}" local payload payload=$(json_object resolved_in_build "$build" resolution_notes "$notes") api POST "/api/bugtracker/v1/manage?action=resolve&id=$id" "$payload" \ || die "Abschluss nicht angenommen: $(api_error)" ok "Item #$id abgeschlossen (behoben in $build)" } cmd_release_claim() { require_token [ $# -ge 1 ] || die "Aufruf: dc-testkit unclaim [grund]" local id="$1"; shift local payload payload=$(json_object note "${1:-Zurueckgegeben durch dc-testkit}") api POST "/api/bugtracker/v1/manage?action=release&id=$id" "$payload" \ || die "Rueckgabe nicht angenommen: $(api_error)" ok "Item #$id zurueckgegeben" } # --------------------------------------------------------------------------- # run - der ganze Durchlauf # # Lizenz holen, Agent besorgen, in einen leeren Sandkasten ausliefern, Ergebnis # pruefen. Scheitert ein Schritt, wird der Befund gemeldet statt nur gedruckt - # denn ein fehlgeschlagener Testlauf, von dem nur das Terminal weiss, ist kein # Testlauf gewesen. # # Die Meldung ist ueber client_ref entstoert: Derselbe Fehlschlag in fuenf # Naechten erzeugt ein Item, nicht fuenf. # --------------------------------------------------------------------------- cmd_run() { require_project; require_token local report=1 [ "${1:-}" = '--no-report' ] && report=0 local log log="$(state_dir)/last-run.log" ensure_state local failed_step='' # Der Schritt laeuft in einer SUBSHELL, und das ist der Punkt: die() beendet # mit exit. Ohne Subshell risse der erste Fehlschlag das ganze Skript mit - # und ausgerechnet der Durchlauf, der Fehlschlaege melden soll, kaeme nie # bis zur Meldung. Dateien, die ein Schritt anlegt (Schluessel, Binary, # Sandkasten), ueberleben die Subshell ohnehin; nur Variablen nicht, und die # gibt niemand weiter. _stage() { local name="$1"; shift step "$name" if ( "$@" ) >>"$log" 2>&1; then ok "$name" return 0 fi failed_step="$name" warn "$name fehlgeschlagen" return 1 } : > "$log" { _stage "DEV-Lizenz holen" cmd_license \ && _stage "update-agent besorgen" cmd_agent \ && _stage "Erstauslieferung" cmd_install \ && _stage "Ergebnis pruefen" cmd_verify \ && _stage "Update auf latest" cmd_update \ && _stage "Ergebnis erneut pruefen" cmd_verify } || true if [ -z "$failed_step" ]; then printf '\n%sDurchlauf vollstaendig.%s\n' "$C_GREEN" "$C_RESET" dim "Protokoll: $log" return 0 fi printf '\n%sDurchlauf abgebrochen bei: %s%s\n' "$C_RED" "$failed_step" "$C_RESET" dim "Protokoll: $log" if [ "$report" -eq 1 ]; then step "Befund melden" tail -c 4000 "$log" | cmd_report \ --type bug \ --title "Testlauf scheitert: $failed_step ($DC_PROJECT/$DC_CHANNEL)" \ --severity high \ --error "$(tail -n 1 "$log")" \ --ref "testkit-run-$(printf '%s|%s|%s' "$DC_PROJECT" "$DC_CHANNEL" "$failed_step" | sha256sum | cut -c1-24)" fi return 1 } # --------------------------------------------------------------------------- # cleanup - nichts zuruecklassen # # Die Regel der Testumgebung lautet: Nichts Dauerhaftes hinterlassen, was nicht # in der Belegungsliste steht. Der DEV-Schluessel gehoert dazu - er laeuft zwar # von selbst ab, aber ein zurueckgegebener Schluessel macht sofort Platz fuer # die naechste Maschine. # --------------------------------------------------------------------------- cmd_cleanup() { require_project local only_sandbox=0 [ "${1:-}" = '--sandbox' ] && only_sandbox=1 local dir dir="$(sandbox_dir)" if [ -d "$dir" ]; then rm -rf "$dir" ok "Sandkasten entfernt: $dir" fi [ "$only_sandbox" -eq 1 ] && return 0 if [ -f "$(key_file)" ] && [ -n "$DC_TOKEN" ]; then cmd_license --revoke || warn "DEV-Schluessel liess sich nicht zurueckgeben - er laeuft von selbst ab." fi rm -f "$(agent_bin)" "$(state_dir)/last-run.log" ok "Aufgeraeumt." } # --------------------------------------------------------------------------- # Einstieg # --------------------------------------------------------------------------- usage() { cat <] [--channel ] [--url ] Einrichten und pruefen doctor Werkzeuge, Netz, eigener Stand, Token und Projekt selfupdate [--pruefen] Dieses Skript auf die ausgelieferte Fassung bringen license [--ttl N] [--print] [--revoke] DEV-Lizenzschluessel holen, verlaengern, zurueckgeben agent [--force] update-agent herunterladen, Pruefsumme abgleichen Auslieferung pruefen check Gibt es ein Update? install Erstauslieferung in einen leeren Sandkasten update [--version ] Update oder Rueckfall auf eine aeltere Fassung repair Integritaetsreparatur list Verfuegbare Fassungen verify Was liegt im Sandkasten, und passt es zum Server? run [--no-report] Alles nacheinander; meldet, was scheitert Bugtracker projects Wohin darf ich melden? Alle Projekt-Slugs report --title T --desc D [--type bug|feature] [--severity S] [--file F --line N] [--error M] [--stack-file P] report --project FREMDER-SLUG --evidence "warum dort und nicht hier" Befund im FREMDEN Projekt melden. Begruendung mind. 40 Zeichen, Schweregrad wird auf medium gedeckelt, landet in triage_external. next [limit] Naechstes Item uebernehmen - auch Nachpruefungen comment [aktion] resolve [notizen] "ich habe es behoben" confirm [notiz] "der Fix eines anderen traegt" -> schliesst unclaim [grund] Uebernahme zurueckgeben Oberflaeche pruefen (braucht DC_DISPLAY) display [status|start|stop] Eigenes virtuelles Display shot Aufnahme nach /tmp/_.png Aufraeumen cleanup [--sandbox] Sandkasten weg, Schluessel zurueckgeben Schweregrade: idea, wishlist, low, medium, high, critical Typen: bug, feature ("idea" ist ein Schweregrad, kein Typ) Konfiguration: $CONFIG_FILE DC_URL, DC_TOKEN, DC_PROJECT, DC_CHANNEL, DC_INSTANCE, DC_DISPLAY DC_PROJECT_DIR - Projekt-Repo; ohne DC_TOKEN wird es aus dessen .mcp.json gelesen (env.DC_TOKEN) EOF } main() { while [ $# -gt 0 ]; do case "$1" in --project) DC_PROJECT="$2"; shift 2 ;; --channel) DC_CHANNEL="$2"; shift 2 ;; --url) DC_URL="${2%/}"; shift 2 ;; --instance) DC_INSTANCE="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; --version) printf 'dc-testkit %s\n' "$DC_TESTKIT_VERSION"; exit 0 ;; *) break ;; esac done local command="${1:-}" [ $# -gt 0 ] && shift token_aufloesen case "$command" in doctor) cmd_doctor "$@" ;; selfupdate) cmd_selfupdate "$@" ;; display) cmd_display "$@" ;; shot) cmd_shot "$@" ;; license) cmd_license "$@" ;; agent) cmd_agent "$@" ;; check) cmd_check "$@" ;; install) cmd_install "$@" ;; update) cmd_update "$@" ;; repair) cmd_repair "$@" ;; list) cmd_list "$@" ;; verify) cmd_verify "$@" ;; run) cmd_run "$@" ;; projects) cmd_projects "$@" ;; report) cmd_report "$@" ;; next) cmd_next "$@" ;; comment) cmd_comment "$@" ;; resolve) cmd_resolve "$@" ;; confirm) cmd_confirm "$@" ;; unclaim) cmd_release_claim "$@" ;; cleanup) cmd_cleanup "$@" ;; ''|help) usage ;; *) die "Unbekannter Befehl: $command (dc-testkit --help)" ;; esac } main "$@"