feat(2.7.0): Roadmap im DC, Testinstanz mit Testtor, Supervisor und MCP
Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.
ROADMAP IM DEPLOYMENTCENTER (2.7.0)
Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.
Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.
- Migration 016: epic, roadmap_rank, target_build, defer_trigger,
doc_anchor, Status "deferred", Tabelle roadmap_epics.
KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
- Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
- Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
die Kennung nach einer Loeschung neu vergeben wurde.
- Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
Repository, damit sie auf allen drei Schreibwegen gleich greift.
- Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
dc_roadmap und dc_roadmap_set im MCP-Adapter.
- cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
--check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
- docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.
TESTINSTANZ UND TESTTOR
- dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
Basic-Auth davor, Rocket.Chat aus.
- 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
- scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
die Testinstanz und laesst dann den Testlauf laufen. Erst bei
Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
- tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
nicht versehentlich auf dc.mhdf.de landen.
- Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.
SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)
- Migration 013: projektuebergreifend melden (dev_agent, evidence,
triage_external, affects).
- Migration 014: supervisor_findings, Katalog, fuenf serverseitige
Detektoren im Tick.
- Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
- DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
- MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
von Fremddaten gegen Prompt-Injektion.
BEHOBEN
- observe und status im Supervisor legten den Zustand des Befundes als
"status" in die Antwort. Http::ok() merged ['status' => 'success']
darunter, also gewann der Befundzustand - eine angenommene Meldung kam
als status="open" an. Heisst jetzt finding_status. War Item #59.
Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d0261306c
commit
1d5ac92051
@@ -0,0 +1,602 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gemeinsamer Unterbau der Testlaeufe gegen die Testinstanz.
|
||||
|
||||
Bewusst ohne pytest und ohne requests: Der Testlauf soll auf jedem Rechner
|
||||
und auf der Test-VM ohne vorherige Installation starten koennen. Sobald ein
|
||||
Testwerkzeug erst eingerichtet werden muss, wird es im Zweifel nicht
|
||||
ausgefuehrt - und eine Testsuite, die niemand startet, findet nichts.
|
||||
|
||||
Aufbau:
|
||||
Client - HTTP-Aufrufe gegen die Instanz, mit Basic-Auth und Token
|
||||
Ctx - was ein Testfall an den naechsten weitergibt (IDs, Tokens)
|
||||
@test - Registrierung; die Reihenfolge der Registrierung ist die
|
||||
Reihenfolge der Ausfuehrung
|
||||
run - Ausfuehrung, Ausgabe, Bericht
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parents[2]
|
||||
DEV_CONFIG = PROJECT_DIR / 'scripts' / 'deploy_dev_config.json'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adressfamilie
|
||||
#
|
||||
# Die Hoster-Adressen haben einen AAAA-Eintrag. Ist von einem Arbeitsplatz aus
|
||||
# keine IPv6-Route vorhanden, laeuft jeder einzelne Aufruf zuerst 21 Sekunden
|
||||
# in einen TCP-Timeout und faellt erst dann auf IPv4 zurueck - aus einem
|
||||
# Testlauf von einer Minute werden 40. curl faellt sofort zurueck (Happy
|
||||
# Eyeballs), Python nicht; deshalb steht das hier und nicht im Netzwerk.
|
||||
#
|
||||
# Einmal am Anfang geprueft, danach steht die Entscheidung fuer den ganzen
|
||||
# Lauf. Sie betrifft nur, wie der Testlauf die Instanz erreicht - nicht, was
|
||||
# er prueft.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ECHTES_GETADDRINFO = socket.getaddrinfo
|
||||
|
||||
|
||||
def erreichbar(host, port, familie, timeout=3.0):
|
||||
try:
|
||||
infos = _ECHTES_GETADDRINFO(host, port, familie, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for info in infos:
|
||||
s = socket.socket(info[0], socket.SOCK_STREAM)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect(info[4])
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
finally:
|
||||
s.close()
|
||||
return False
|
||||
|
||||
|
||||
def waehle_adressfamilie(host, port=443, erzwinge=None):
|
||||
"""
|
||||
Liefert eine kurze Beschreibung fuer die Ausgabe und schaltet notfalls
|
||||
auf IPv4 um.
|
||||
"""
|
||||
if erzwinge == 'ipv4' or (erzwinge is None and not erreichbar(host, port, socket.AF_INET6)):
|
||||
def nur_ipv4(*a, **kw):
|
||||
args = list(a)
|
||||
if len(args) >= 3:
|
||||
args[2] = socket.AF_INET
|
||||
else:
|
||||
kw['family'] = socket.AF_INET
|
||||
return _ECHTES_GETADDRINFO(*args, **kw)
|
||||
socket.getaddrinfo = nur_ipv4
|
||||
return 'IPv4 (IPv6 nicht erreichbar)' if erzwinge is None else 'IPv4 (erzwungen)'
|
||||
return 'IPv6/IPv4'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ausgabe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Farbe:
|
||||
"""ANSI-Farben, aber nur wenn die Ausgabe wirklich ein Terminal ist."""
|
||||
aktiv = sys.stdout.isatty() and os.name != 'nt' or os.environ.get('DC_FARBE') == '1'
|
||||
|
||||
@classmethod
|
||||
def _f(cls, code, text):
|
||||
return "\033[%sm%s\033[0m" % (code, text) if cls.aktiv else text
|
||||
|
||||
@classmethod
|
||||
def gruen(cls, t):
|
||||
return cls._f('32', t)
|
||||
|
||||
@classmethod
|
||||
def rot(cls, t):
|
||||
return cls._f('31', t)
|
||||
|
||||
@classmethod
|
||||
def gelb(cls, t):
|
||||
return cls._f('33', t)
|
||||
|
||||
@classmethod
|
||||
def grau(cls, t):
|
||||
return cls._f('90', t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fehlerarten
|
||||
#
|
||||
# Der Unterschied zwischen "die Anwendung ist kaputt" und "dieser Testfall
|
||||
# laesst sich hier nicht pruefen" muss im Bericht sichtbar bleiben. Sonst
|
||||
# gewoehnt man sich an rote Zeilen, die keine sind.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Fehlschlag(AssertionError):
|
||||
"""Die Anwendung hat sich anders verhalten als erwartet."""
|
||||
|
||||
|
||||
class Uebersprungen(Exception):
|
||||
"""Der Testfall ist unter diesen Bedingungen nicht pruefbar."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Antwort:
|
||||
def __init__(self, status, headers, body, ms, url, method):
|
||||
self.status = status
|
||||
self.headers = {k.lower(): v for k, v in (headers or {}).items()}
|
||||
self.body = body
|
||||
self.ms = ms
|
||||
self.url = url
|
||||
self.method = method
|
||||
try:
|
||||
self.json = json.loads(body) if body else None
|
||||
except Exception:
|
||||
self.json = None
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return isinstance(self.json, dict) and self.json.get('status') == 'success'
|
||||
|
||||
@property
|
||||
def fehlercode(self):
|
||||
if isinstance(self.json, dict) and isinstance(self.json.get('error'), dict):
|
||||
return self.json['error'].get('code')
|
||||
return None
|
||||
|
||||
def feld(self, *pfad, default=None):
|
||||
"""Wert aus verschachtelter Antwort holen: resp.feld('state', 'tables')."""
|
||||
wert = self.json
|
||||
for teil in pfad:
|
||||
if isinstance(wert, dict) and teil in wert:
|
||||
wert = wert[teil]
|
||||
elif isinstance(wert, list) and isinstance(teil, int) and -len(wert) <= teil < len(wert):
|
||||
wert = wert[teil]
|
||||
else:
|
||||
return default
|
||||
return wert
|
||||
|
||||
def kurz(self, n=400):
|
||||
text = self.body or ''
|
||||
return text[:n] + (' ...' if len(text) > n else '')
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s %s -> %d>" % (self.method, self.url, self.status)
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
HTTP-Client fuer die Testinstanz.
|
||||
|
||||
Zur Aufteilung der beiden Berechtigungen: Die Basic-Auth der Testinstanz
|
||||
geht in den Authorization-Header, das Token der Anwendung in
|
||||
X-Agent-Token. Beide in denselben Header zu packen ginge nicht - und die
|
||||
Anwendung akzeptiert X-Agent-Token ausdruecklich (Http::bearerToken).
|
||||
Wer den Bearer-Weg pruefen will, ruft mit basic=False und bearer=True auf;
|
||||
dann greift die Ausnahme der .htaccess fuer /api/.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url, basic_user=None, basic_pass=None, token=None, timeout=45):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.basic_user = basic_user
|
||||
self.basic_pass = basic_pass
|
||||
self.token = token
|
||||
self.timeout = timeout
|
||||
self.aufrufe = 0
|
||||
# Die Testinstanz laeuft hinter einem regulaeren Zertifikat; ein
|
||||
# eigener Kontext nur, damit ein Hoster-Zwischenzertifikat den
|
||||
# Testlauf nicht zum Erliegen bringt.
|
||||
self.ssl_ctx = ssl.create_default_context()
|
||||
|
||||
def basic_header(self):
|
||||
roh = ("%s:%s" % (self.basic_user, self.basic_pass)).encode('utf-8')
|
||||
return 'Basic ' + base64.b64encode(roh).decode('ascii')
|
||||
|
||||
def request(self, method, pfad, body=None, query=None, token='standard',
|
||||
bearer=False, basic=True, headers=None, roh_body=None):
|
||||
url = self.base_url + pfad
|
||||
if query:
|
||||
url += ('&' if '?' in url else '?') + urllib.parse.urlencode(query)
|
||||
|
||||
kopf = {'Accept': 'application/json', 'User-Agent': 'dc-e2e/1.0'}
|
||||
|
||||
if basic and self.basic_user:
|
||||
kopf['Authorization'] = self.basic_header()
|
||||
|
||||
# token='standard' -> das Token des Laufs; None -> keins; sonst der Wert
|
||||
wert = self.token if token == 'standard' else token
|
||||
if wert:
|
||||
if bearer:
|
||||
kopf['Authorization'] = 'Bearer ' + wert
|
||||
else:
|
||||
kopf['X-Agent-Token'] = wert
|
||||
|
||||
daten = None
|
||||
if roh_body is not None:
|
||||
daten = roh_body if isinstance(roh_body, bytes) else roh_body.encode('utf-8')
|
||||
kopf.setdefault('Content-Type', 'application/json')
|
||||
elif body is not None:
|
||||
daten = json.dumps(body).encode('utf-8')
|
||||
kopf['Content-Type'] = 'application/json'
|
||||
|
||||
kopf.update(headers or {})
|
||||
|
||||
req = urllib.request.Request(url, data=daten, headers=kopf, method=method)
|
||||
start = time.time()
|
||||
|
||||
# Ein einziger Wiederholungsversuch, und nur wenn die Verbindung
|
||||
# selbst gescheitert ist - nie bei einer HTTP-Antwort. Ein
|
||||
# zurueckgesetztes TCP sagt nichts ueber die Anwendung aus; ein
|
||||
# HTTP 500 dagegen schon, und das darf kein Wiederholungsversuch
|
||||
# verschlucken.
|
||||
for versuch in (1, 2):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ssl_ctx) as r:
|
||||
inhalt = r.read().decode('utf-8', 'replace')
|
||||
antwort = Antwort(r.status, dict(r.headers), inhalt,
|
||||
int((time.time() - start) * 1000), url, method)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
inhalt = e.read().decode('utf-8', 'replace')
|
||||
antwort = Antwort(e.code, dict(e.headers), inhalt,
|
||||
int((time.time() - start) * 1000), url, method)
|
||||
break
|
||||
except urllib.error.URLError as e:
|
||||
if versuch == 2:
|
||||
raise Fehlschlag("%s %s nicht erreichbar: %s" % (method, url, e.reason))
|
||||
time.sleep(0.5)
|
||||
|
||||
self.aufrufe += 1
|
||||
return antwort
|
||||
|
||||
def get(self, pfad, **kw):
|
||||
return self.request('GET', pfad, **kw)
|
||||
|
||||
def post(self, pfad, body=None, **kw):
|
||||
return self.request('POST', pfad, body=body, **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pruefungen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def erwarte(bedingung, meldung):
|
||||
if not bedingung:
|
||||
raise Fehlschlag(meldung)
|
||||
|
||||
|
||||
def erwarte_status(resp, *codes):
|
||||
if resp.status not in codes:
|
||||
raise Fehlschlag("%s %s: HTTP %d erwartet %s | %s" % (
|
||||
resp.method, resp.url, resp.status,
|
||||
'/'.join(str(c) for c in codes), resp.kurz(300)))
|
||||
return resp
|
||||
|
||||
|
||||
def erwarte_erfolg(resp):
|
||||
erwarte_status(resp, 200, 201)
|
||||
if not resp.ok:
|
||||
raise Fehlschlag("%s %s: Antwort ohne status=success | %s" % (
|
||||
resp.method, resp.url, resp.kurz(300)))
|
||||
return resp
|
||||
|
||||
|
||||
def erwarte_fehler(resp, http=None, code=None):
|
||||
if http is not None:
|
||||
erwarte_status(resp, *(http if isinstance(http, (list, tuple)) else [http]))
|
||||
erwarte(isinstance(resp.json, dict) and resp.json.get('status') == 'error',
|
||||
"%s %s: Fehlerantwort erwartet, kam: %s" % (resp.method, resp.url, resp.kurz(300)))
|
||||
if code is not None:
|
||||
codes = code if isinstance(code, (list, tuple)) else [code]
|
||||
erwarte(resp.fehlercode in codes,
|
||||
"%s %s: error.code %s erwartet, war %r" % (
|
||||
resp.method, resp.url, '/'.join(codes), resp.fehlercode))
|
||||
return resp
|
||||
|
||||
|
||||
def ueberspringen(grund):
|
||||
raise Uebersprungen(grund)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registrierung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Fall:
|
||||
def __init__(self, gruppe, name, fn, doc):
|
||||
self.gruppe = gruppe
|
||||
self.name = name
|
||||
self.fn = fn
|
||||
self.doc = doc
|
||||
|
||||
|
||||
FAELLE = []
|
||||
|
||||
|
||||
def test(gruppe, name):
|
||||
"""Registriert einen Testfall. Reihenfolge der Registrierung = Ablauf."""
|
||||
def deco(fn):
|
||||
FAELLE.append(Fall(gruppe, name, fn, (fn.__doc__ or '').strip()))
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
class Ctx:
|
||||
"""
|
||||
Der Zustand, den Testfaelle teilen.
|
||||
|
||||
Ein Bugtracker-Item muss angelegt sein, bevor es sich uebernehmen laesst;
|
||||
ein Sub-Token muss existieren, bevor sich pruefen laesst, was es nicht
|
||||
darf. Solche Ketten hier abzubilden ist ehrlicher, als jedem Testfall ein
|
||||
eigenes Item unterzuschieben - geprueft wird schliesslich der Ablauf, den
|
||||
ein Agent tatsaechlich nimmt.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg, client):
|
||||
self.cfg = cfg
|
||||
self.client = client
|
||||
self.daten = {}
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k not in self.daten:
|
||||
ueberspringen("Voraussetzung fehlt: %s (vorheriger Testfall nicht durchgelaufen)" % k)
|
||||
return self.daten[k]
|
||||
|
||||
def __setitem__(self, k, v):
|
||||
self.daten[k] = v
|
||||
|
||||
def get(self, k, default=None):
|
||||
return self.daten.get(k, default)
|
||||
|
||||
def hat(self, k):
|
||||
return k in self.daten
|
||||
|
||||
# -- Steuerung der Instanz ------------------------------------------
|
||||
def testctl(self, action, **params):
|
||||
body = dict(params)
|
||||
body['action'] = action
|
||||
return self.client.post('/testctl.php', body=body,
|
||||
token=self.cfg['keys']['shared_key'])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konfiguration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lade_bekannte_befunde():
|
||||
"""
|
||||
Testfaelle, die einen gemeldeten, noch offenen Befund beschreiben.
|
||||
|
||||
Siehe die Begruendung in bekannte_befunde.json selbst: Ein dauerhaft roter
|
||||
Lauf ist genauso wertlos wie ein geloeschter Testfall - beim einen sieht
|
||||
niemand mehr hin, beim anderen ist der Befund vergessen.
|
||||
"""
|
||||
p = Path(__file__).resolve().parent / 'bekannte_befunde.json'
|
||||
if not p.exists():
|
||||
return {}
|
||||
with open(p, 'r', encoding='utf-8') as f:
|
||||
roh = json.load(f)
|
||||
return {k: v for k, v in roh.items() if not k.startswith('_')}
|
||||
|
||||
|
||||
def lade_config(pfad=None):
|
||||
p = Path(pfad) if pfad else DEV_CONFIG
|
||||
if not p.exists():
|
||||
print("Konfiguration fehlt: %s" % p)
|
||||
print("Vorlage: scripts/deploy_dev_config.example.json")
|
||||
sys.exit(2)
|
||||
with open(p, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ausfuehrung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(cfg, gruppen=None, muster=None, reset=True, bericht_datei=None, stoppen=False,
|
||||
adressfamilie=None):
|
||||
host = urllib.parse.urlparse(cfg['base_url']).hostname
|
||||
weg = waehle_adressfamilie(host, 443, adressfamilie)
|
||||
|
||||
client = Client(cfg['base_url'],
|
||||
cfg['basic_auth']['user'], cfg['basic_auth']['pass'])
|
||||
ctx = Ctx(cfg, client)
|
||||
|
||||
faelle = FAELLE
|
||||
if gruppen:
|
||||
faelle = [f for f in faelle if f.gruppe in gruppen]
|
||||
if muster:
|
||||
m = muster.lower()
|
||||
faelle = [f for f in faelle if m in f.name.lower() or m in f.gruppe.lower()]
|
||||
|
||||
if not faelle:
|
||||
print("Kein Testfall passt zur Auswahl.")
|
||||
return 2
|
||||
|
||||
print("Testinstanz : %s (%s)" % (cfg['base_url'], weg))
|
||||
print("Testfaelle : %d in %d Gruppe(n)" % (
|
||||
len(faelle), len(set(f.gruppe for f in faelle))))
|
||||
|
||||
if reset:
|
||||
print("")
|
||||
print("Ausgangszustand herstellen ...")
|
||||
r = ctx.testctl('reset', confirm=cfg['db']['name'],
|
||||
admin_user=cfg['admin']['user'], admin_pass=cfg['admin']['pass'])
|
||||
if not r.ok:
|
||||
print(Farbe.rot(" Zuruecksetzen fehlgeschlagen: %s" % r.kurz(500)))
|
||||
return 2
|
||||
ctx['master_token'] = r.feld('master', 'raw_token')
|
||||
ctx['admin'] = r.feld('admin')
|
||||
client.token = ctx['master_token']
|
||||
print(" %d Migration(en), %d Tabellen, Master-Token bereit." % (
|
||||
len(r.feld('migration', 'applied', default=[])),
|
||||
r.feld('state', 'tables', default=0)))
|
||||
else:
|
||||
if not cfg.get('master_token'):
|
||||
print(Farbe.gelb(" Ohne --reset wird ein master_token in der Konfiguration erwartet."))
|
||||
ctx['master_token'] = cfg.get('master_token')
|
||||
client.token = ctx['master_token']
|
||||
|
||||
ergebnisse = []
|
||||
gruppe_aktiv = None
|
||||
bekannte = lade_bekannte_befunde()
|
||||
start_gesamt = time.time()
|
||||
|
||||
for fall in faelle:
|
||||
if fall.gruppe != gruppe_aktiv:
|
||||
gruppe_aktiv = fall.gruppe
|
||||
print("")
|
||||
print(Farbe.grau("--- %s %s" % (gruppe_aktiv, '-' * max(0, 58 - len(gruppe_aktiv)))))
|
||||
|
||||
start = time.time()
|
||||
zustand, meldung = 'ok', ''
|
||||
try:
|
||||
fall.fn(ctx)
|
||||
except Uebersprungen as e:
|
||||
zustand, meldung = 'skip', str(e)
|
||||
except Fehlschlag as e:
|
||||
zustand, meldung = 'fail', str(e)
|
||||
except Exception as e: # unerwarteter Fehler im Testfall selbst
|
||||
zustand = 'fail'
|
||||
meldung = "%s: %s" % (type(e).__name__, e)
|
||||
ms = int((time.time() - start) * 1000)
|
||||
|
||||
# Bekannter, gemeldeter Befund? Dann ist ein Fehlschlag erwartet und
|
||||
# blockiert nichts - und ein unerwartetes Bestehen ist die Nachricht.
|
||||
bekannt = bekannte.get("%s/%s" % (fall.gruppe, fall.name))
|
||||
item = bekannt.get('item') if bekannt else None
|
||||
if bekannt and zustand == 'fail':
|
||||
zustand = 'bekannt'
|
||||
elif bekannt and zustand == 'ok':
|
||||
zustand = 'behoben'
|
||||
|
||||
ergebnisse.append({'gruppe': fall.gruppe, 'name': fall.name,
|
||||
'zustand': zustand, 'ms': ms, 'meldung': meldung,
|
||||
'zweck': fall.doc, 'item': item})
|
||||
|
||||
if zustand == 'ok':
|
||||
print(" %s %-52s %s" % (Farbe.gruen('OK '), fall.name, Farbe.grau('%4d ms' % ms)))
|
||||
elif zustand == 'bekannt':
|
||||
print(" %s %-52s %s" % (Farbe.gelb('BEK '), fall.name,
|
||||
Farbe.grau('bekannter Befund, Item #%s' % item)))
|
||||
elif zustand == 'behoben':
|
||||
print(" %s %-52s %s" % (Farbe.gruen('NEU!'), fall.name,
|
||||
Farbe.gruen('besteht wieder - Item #%s schliessen '
|
||||
'und Eintrag aus bekannte_befunde.json '
|
||||
'entfernen' % item)))
|
||||
elif zustand == 'skip':
|
||||
print(" %s %-52s %s" % (Farbe.gelb('UEBR'), fall.name, Farbe.grau(meldung[:60])))
|
||||
else:
|
||||
print(" %s %-52s %s" % (Farbe.rot('FEHL'), fall.name, Farbe.grau('%4d ms' % ms)))
|
||||
for zeile in meldung.splitlines():
|
||||
print(" %s" % Farbe.rot(zeile))
|
||||
if stoppen:
|
||||
break
|
||||
|
||||
dauer = time.time() - start_gesamt
|
||||
n_ok = sum(1 for e in ergebnisse if e['zustand'] == 'ok')
|
||||
n_fail = sum(1 for e in ergebnisse if e['zustand'] == 'fail')
|
||||
n_skip = sum(1 for e in ergebnisse if e['zustand'] == 'skip')
|
||||
n_bekannt = sum(1 for e in ergebnisse if e['zustand'] == 'bekannt')
|
||||
n_behoben = sum(1 for e in ergebnisse if e['zustand'] == 'behoben')
|
||||
|
||||
# Ein Lauf, in dem fast nichts lief, ist kein bestandener Lauf.
|
||||
#
|
||||
# Uebersprungene Faelle zaehlten bisher nirgends hinein. Damit endete
|
||||
# `run.py --nur roadmap` mit "2 bestanden, 0 fehlgeschlagen, 5
|
||||
# uebersprungen" und Rueckgabewert 0 - gruen, obwohl die Voraussetzung
|
||||
# (ctx['freies_item'] aus der Gruppe bugtracker) vom Filter
|
||||
# ausgeschlossen war und fuenf von sieben Faellen gar nicht liefen.
|
||||
#
|
||||
# Das Tor vor der Produktion war davon nie betroffen, dort laeuft alles.
|
||||
# Betroffen war der Moment, in dem ein Mensch einem einzelnen Ergebnis
|
||||
# glauben soll - und genau dort muss das Signal ehrlich sein.
|
||||
#
|
||||
# Kein Fehlschlag: Nichts ist kaputt. Aber auch kein Erfolg, denn geprueft
|
||||
# wurde es nicht. Dafuer gibt es den dritten Rueckgabewert, den run.py
|
||||
# ohnehin schon kennt: 2 = konnte nicht ordentlich laufen.
|
||||
unbrauchbar = n_fail == 0 and n_skip > (n_ok + n_behoben + n_bekannt)
|
||||
|
||||
print("")
|
||||
print("=" * 70)
|
||||
zusammenfassung = "%d bestanden, %d fehlgeschlagen, %d uebersprungen" % (
|
||||
n_ok + n_behoben, n_fail, n_skip)
|
||||
if n_bekannt:
|
||||
zusammenfassung += ", %d bekannte Befunde" % n_bekannt
|
||||
zusammenfassung += " (%.1f s, %d Aufrufe)" % (dauer, client.aufrufe)
|
||||
|
||||
if n_fail:
|
||||
print(Farbe.rot(zusammenfassung))
|
||||
elif unbrauchbar:
|
||||
print(Farbe.gelb(zusammenfassung))
|
||||
else:
|
||||
print(Farbe.gruen(zusammenfassung))
|
||||
|
||||
if unbrauchbar:
|
||||
print("")
|
||||
print(Farbe.gelb(
|
||||
"Mehr uebersprungen als geprueft - dieser Lauf sagt nichts aus."))
|
||||
print(Farbe.grau(
|
||||
" Uebersprungen wird, wenn eine Voraussetzung aus einem frueheren"))
|
||||
print(Farbe.grau(
|
||||
" Testfall fehlt. Bei --gruppe/--nur ist der meist mit weggefiltert:"))
|
||||
print(Farbe.grau(
|
||||
" ctx['freies_item'] etwa entsteht in der Gruppe bugtracker."))
|
||||
print(Farbe.grau(
|
||||
" Abhilfe: die Gruppe mit aufnehmen, oder ohne Filter laufen lassen."))
|
||||
|
||||
if n_fail:
|
||||
print("")
|
||||
print("Fehlgeschlagen:")
|
||||
for e in ergebnisse:
|
||||
if e['zustand'] == 'fail':
|
||||
print(" - %s / %s" % (e['gruppe'], e['name']))
|
||||
|
||||
if n_bekannt:
|
||||
print("")
|
||||
print("Bekannte Befunde (gemeldet, noch offen - blockieren nichts):")
|
||||
for e in ergebnisse:
|
||||
if e['zustand'] == 'bekannt':
|
||||
print(" - Item #%s %s / %s" % (e['item'], e['gruppe'], e['name']))
|
||||
|
||||
if n_behoben:
|
||||
print("")
|
||||
print(Farbe.gruen("Wieder bestanden - Eintrag aus bekannte_befunde.json entfernen:"))
|
||||
for e in ergebnisse:
|
||||
if e['zustand'] == 'behoben':
|
||||
print(" - Item #%s %s / %s" % (e['item'], e['gruppe'], e['name']))
|
||||
|
||||
if bericht_datei:
|
||||
bericht = {
|
||||
'instanz': cfg['base_url'],
|
||||
'zeit_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'dauer_s': round(dauer, 2),
|
||||
'bestanden': n_ok + n_behoben, 'fehlgeschlagen': n_fail,
|
||||
'uebersprungen': n_skip, 'bekannte_befunde': n_bekannt,
|
||||
'wieder_bestanden': n_behoben,
|
||||
# Damit ein ausgewerteter Bericht dieselbe Auskunft gibt wie der
|
||||
# Rueckgabewert - sonst laese ein Werkzeug "0 fehlgeschlagen" und
|
||||
# hielte einen Lauf fuer bestanden, der nichts geprueft hat.
|
||||
'aussagekraeftig': not unbrauchbar,
|
||||
'faelle': ergebnisse,
|
||||
}
|
||||
Path(bericht_datei).write_text(
|
||||
json.dumps(bericht, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
print("")
|
||||
print("Bericht: %s" % bericht_datei)
|
||||
|
||||
if n_fail:
|
||||
return 1
|
||||
return 2 if unbrauchbar else 0
|
||||
Reference in New Issue
Block a user